cuelo 0.10.69 → 0.10.71
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +23 -23
- package/.next/build-manifest.json +2 -2
- package/.next/prerender-manifest.json +3 -3
- package/.next/required-server-files.js +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/update-maintenance/route.js +3 -3
- package/.next/server/app/api/updates/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/page.js +3 -3
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/recover/page_client-reference-manifest.js +1 -1
- package/.next/server/app/recover.html +1 -1
- package/.next/server/app/recover.rsc +2 -2
- package/.next/server/app/recover.segments/_full.segment.rsc +2 -2
- package/.next/server/app/recover.segments/_head.segment.rsc +1 -1
- package/.next/server/app/recover.segments/_index.segment.rsc +2 -2
- package/.next/server/app/recover.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/recover.segments/recover/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/recover.segments/recover.segment.rsc +1 -1
- package/.next/server/app-paths-manifest.json +23 -23
- package/.next/server/chunks/1008.js +6 -5
- package/.next/server/chunks/402.js +1 -1
- package/.next/server/chunks/6429.js +4 -4
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/{layout-d9ccde470db63593.js → layout-69929b1152bb6134.js} +1 -1
- package/.next/static/chunks/app/{page-77d30b36da899bd2.js → page-07ae1110b1e4a1b6.js} +3 -3
- package/.next/trace +108 -108
- package/.next/trace-build +1 -1
- package/README.md +1 -1
- package/Tools/CUELO_Setup/files/native-runtime-patch.js +1 -1
- package/Tools/OMP_Global_Config/agent/extensions/command-guard/index.ts +174 -5
- package/Tools/OMP_Global_Config/agent/extensions/command-guard/task-guard.ts +216 -11
- package/Tools/OMP_Global_Config/agent/extensions/cross-review.ts +4 -2
- package/Tools/OMP_Global_Config/agent/extensions/edit-lessons.ts +114 -23
- package/Tools/OMP_Global_Config/agent/extensions/jev-runtime.ts +14 -15
- package/Tools/OMP_Global_Config/agent/extensions/lib/cross-review.ts +53 -10
- package/Tools/OMP_Global_Config/agent/extensions/lib/routing-ledger.ts +179 -0
- package/Tools/OMP_Global_Config/agent/rules/deploy-ops.md +1 -1
- package/Tools/OMP_Global_Config/agent/rules/harness-policy.json +1 -1
- package/Tools/OMP_Global_Config/agent/rules/subagent.md +4 -2
- package/bin/cuelo.js +3 -1
- package/install.mjs +277 -16
- package/package.json +1 -1
- /package/.next/static/{jzP8OH_sx-Lg230L5ANPF → UEE5papGQ6-VHe2VCtHNf}/_buildManifest.js +0 -0
- /package/.next/static/{jzP8OH_sx-Lg230L5ANPF → UEE5papGQ6-VHe2VCtHNf}/_ssgManifest.js +0 -0
|
@@ -73,6 +73,19 @@ export interface OutcomeRecord extends AttemptIdentity {
|
|
|
73
73
|
*/
|
|
74
74
|
export type RevisionRelation = "same" | "integrated" | "mismatch" | "unknown";
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* revisionRelation이 그 값이 된 원인. 원장 한 행만으로 원인별 집계를 하려는 기록이며 판정이 아니다.
|
|
78
|
+
* - exact: 두 revision 문자열이 공백·대소문자를 빼고 같다.
|
|
79
|
+
* - same-commit: 같은 commit id(앞 7자 이상 일치)를 가리키고 작업 트리 여부도 같다.
|
|
80
|
+
* - linked: 다르지만 integratedFrom이 원 revision과 이어진다(통합 검수).
|
|
81
|
+
* - source-unobserved: 원 revision을 관측하지 못했다.
|
|
82
|
+
* - no-review-revision: Main 검수 revision이 없다(held 등).
|
|
83
|
+
* - unlinked: 다르고 integratedFrom이 없다.
|
|
84
|
+
* - link-mismatch: 다르고 integratedFrom이 있지만 원 revision과 이어지지 않는다.
|
|
85
|
+
*/
|
|
86
|
+
export type RevisionRelationCause =
|
|
87
|
+
| "exact" | "same-commit" | "linked" | "source-unobserved" | "no-review-revision" | "unlinked" | "link-mismatch";
|
|
88
|
+
|
|
76
89
|
/**
|
|
77
90
|
* Main의 명시 수용 판정. 실행 상태(outcome)와 따로 남기며 자동 추정으로 만들지 않는다.
|
|
78
91
|
* - accepted: 그 attempt가 완료되었고 Main이 revision·evidence와 함께 수용했다.
|
|
@@ -93,10 +106,176 @@ export interface VerdictRecord extends AttemptIdentity {
|
|
|
93
106
|
sourceRevision?: string | null;
|
|
94
107
|
/** revision과 sourceRevision의 관계. 이 필드가 생기기 전 판정에는 없으며 그 판정은 재작성하지 않는다. */
|
|
95
108
|
revisionRelation?: RevisionRelation;
|
|
109
|
+
/** revisionRelation이 그 값이 된 원인. 이 필드가 생기기 전 판정에는 없다. */
|
|
110
|
+
revisionRelationCause?: RevisionRelationCause;
|
|
96
111
|
/** Main이 통합 검수라고 명시한 원 revision. 연결 주장일 뿐이며 검수 근거는 reason·evidenceLocators다. */
|
|
97
112
|
integratedFrom?: string;
|
|
98
113
|
}
|
|
99
114
|
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// revision 관계 — 두 revision은 둘 다 자유 서술 문자열이라 문자열 동등 비교만으로는 같은 commit도 mismatch가 된다.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const HEX_TOKEN = /(?<![0-9A-Za-z])[0-9a-f]{7,64}(?![0-9A-Za-z])/gi;
|
|
120
|
+
const CLEAN_TREE = /(?:working[- ]?tree|worktree|워킹\s?트리|작업\s?트리)\s*(?:is\s+)?(?:clean|깨끗)/gi;
|
|
121
|
+
const WORKING_TREE = /working[- ]?tree|worktree|uncommitted|작업\s?트리|워킹\s?트리|미커밋|커밋\s?(?:안|전|없)/i;
|
|
122
|
+
const MIN_LINK_PREFIX = 8;
|
|
123
|
+
/** YYYYMMDD 또는 YYYYMMDDHHMMSS 모양의 날짜·시각은 commit id가 아니다. */
|
|
124
|
+
const DATE_LIKE = /^(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(?:(?:[01]\d|2[0-3])[0-5]\d[0-5]\d)?$/;
|
|
125
|
+
|
|
126
|
+
interface RevisionFacts {
|
|
127
|
+
/** 공백을 접고 소문자로 만든 문자열. */
|
|
128
|
+
text: string;
|
|
129
|
+
/** 문장 속 commit id 후보(7~64자 hex, 숫자와 a-f 문자를 모두 포함). 파일 해시도 같은 규칙으로 잡힌다. */
|
|
130
|
+
commits: string[];
|
|
131
|
+
/**
|
|
132
|
+
* 숫자만이나 a-f 글자만으로 된 7자 이상 hex 토큰(날짜 모양 제외). 진짜 짧은 해시일 수 있지만 숫자·단어와 구분되지 않으므로
|
|
133
|
+
* 다른 쪽의 commits 토큰과 앞부분이 맞을 때만 같은 commit 증거로 쓴다(weak끼리는 쓰지 않는다).
|
|
134
|
+
*/
|
|
135
|
+
weak: string[];
|
|
136
|
+
/** 커밋하지 않은 작업 트리를 가리키는가. "working tree clean"은 작업 트리 변경이 아니다. */
|
|
137
|
+
workingTree: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function revisionFacts(revision: string): RevisionFacts {
|
|
141
|
+
const text = revision.trim().replace(/\s+/g, " ").toLowerCase();
|
|
142
|
+
const tokens = [...new Set(text.match(HEX_TOKEN) ?? [])];
|
|
143
|
+
const mixed = (id: string) => /\d/.test(id) && /[a-f]/.test(id);
|
|
144
|
+
return {
|
|
145
|
+
text,
|
|
146
|
+
commits: tokens.filter(mixed),
|
|
147
|
+
weak: tokens.filter((id) => !mixed(id) && !DATE_LIKE.test(id)),
|
|
148
|
+
workingTree: WORKING_TREE.test(text.replace(CLEAN_TREE, "")),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const prefixRelated = (x: string, y: string) => x.startsWith(y) || y.startsWith(x);
|
|
153
|
+
|
|
154
|
+
/** 같은 revision인가: 문자열이 같거나, 같은 commit id(앞 7자 이상 일치)를 가리키고 작업 트리 여부가 같다. */
|
|
155
|
+
function sameFacts(a: RevisionFacts, b: RevisionFacts): "exact" | "same-commit" | null {
|
|
156
|
+
if (a.text === b.text) return "exact";
|
|
157
|
+
const sharesCommit = a.commits.some((x) => [...b.commits, ...b.weak].some((y) => prefixRelated(x, y)))
|
|
158
|
+
|| a.weak.some((x) => b.commits.some((y) => prefixRelated(x, y)));
|
|
159
|
+
return sharesCommit && a.workingTree === b.workingTree ? "same-commit" : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** integratedFrom이 원 revision과 이어지는가: 같은 revision이거나 한쪽이 다른 쪽의 앞부분이다(최소 8자). */
|
|
163
|
+
function linksTo(from: RevisionFacts, source: RevisionFacts): boolean {
|
|
164
|
+
if (sameFacts(from, source)) return true;
|
|
165
|
+
const [shorter, longer] = from.text.length <= source.text.length ? [from.text, source.text] : [source.text, from.text];
|
|
166
|
+
return shorter.length >= MIN_LINK_PREFIX && longer.startsWith(shorter);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface RevisionRelationResult {
|
|
170
|
+
relation: RevisionRelation;
|
|
171
|
+
cause: RevisionRelationCause;
|
|
172
|
+
/** mismatch일 때 Main이 integratedFrom에 그대로 넘기면 이 attempt의 원 revision으로 이어지는 값. */
|
|
173
|
+
integratedFromHint?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Main 검수 revision과 Maker 원 revision의 관계를 정한다. 순수 함수이며 advisory 기록용이다(게이트가 아니다).
|
|
178
|
+
* 문자열 동등 비교만 하지 않고 같은 commit id(앞 7자 이상)·작업 트리 여부를 함께 본다. commit이 다른 통합 검수는
|
|
179
|
+
* 텍스트만으로 확인할 수 없으므로 여전히 integratedFrom 명시를 요구한다.
|
|
180
|
+
*/
|
|
181
|
+
export function deriveRevisionRelation(args: {
|
|
182
|
+
sourceRevision?: string | null;
|
|
183
|
+
revision?: string | null;
|
|
184
|
+
integratedFrom?: string | null;
|
|
185
|
+
}): RevisionRelationResult {
|
|
186
|
+
const source = (args.sourceRevision ?? "").trim();
|
|
187
|
+
const revision = (args.revision ?? "").trim();
|
|
188
|
+
const from = (args.integratedFrom ?? "").trim();
|
|
189
|
+
if (!source) return { relation: "unknown", cause: "source-unobserved" };
|
|
190
|
+
if (!revision) return { relation: "unknown", cause: "no-review-revision" };
|
|
191
|
+
const sourceFacts = revisionFacts(source);
|
|
192
|
+
const same = sameFacts(revisionFacts(revision), sourceFacts);
|
|
193
|
+
const linked = Boolean(from) && linksTo(revisionFacts(from), sourceFacts);
|
|
194
|
+
// Main이 명시한 통합 연결은 같은 commit 추정으로 덮지 않는다. 문자열이 똑같을 때만 same이 우선한다.
|
|
195
|
+
if (same === "exact" || (same && !linked)) return { relation: "same", cause: same };
|
|
196
|
+
if (linked) return { relation: "integrated", cause: "linked" };
|
|
197
|
+
const hint = source.length <= 200 ? source : sourceFacts.commits[0] ?? sourceFacts.weak[0] ?? source.slice(0, 200);
|
|
198
|
+
return { relation: "mismatch", cause: from ? "link-mismatch" : "unlinked", integratedFromHint: hint };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* mismatch·unknown 기록에 붙이는 설명. mismatch는 관측한 원 revision과 그대로 넘길 integratedFrom 값을 알려 준다.
|
|
203
|
+
* 원 revision 미관측(unknown)은 attempt 번호가 1보다 크면 연속 보고가 prose였을 수 있음을 함께 말한다.
|
|
204
|
+
* 검수 revision이 없는 unknown(held 등)과 same·integrated는 설명이 없다.
|
|
205
|
+
*/
|
|
206
|
+
export function revisionRelationDiagnostic(
|
|
207
|
+
result: RevisionRelationResult,
|
|
208
|
+
args: { sourceRevision?: string | null; revision?: string | null; integratedFrom?: string | null; attempt?: number },
|
|
209
|
+
): string | undefined {
|
|
210
|
+
const revision = (args.revision ?? "").trim();
|
|
211
|
+
const hint = result.integratedFromHint;
|
|
212
|
+
if (result.cause === "link-mismatch" && hint) {
|
|
213
|
+
return `integratedFrom '${(args.integratedFrom ?? "").trim()}'이 이 attempt에서 관측한 Maker 원 revision '${(args.sourceRevision ?? "").trim()}'과 이어지지 않습니다. 검수 revision '${revision}'은 mismatch로 기록했습니다. 이 attempt의 원 revision을 통합한 검수라면 integratedFrom='${hint}'로 다시 기록하고, 다른 attempt의 결과라면 그 attempt identity로 기록하세요.`;
|
|
214
|
+
}
|
|
215
|
+
if (result.cause === "unlinked" && hint) {
|
|
216
|
+
return `Maker 원 revision '${(args.sourceRevision ?? "").trim()}'과 검수 revision '${revision}'이 다르고 연결 주장이 없어 mismatch로 기록했습니다. 통합·커밋 뒤 새 revision을 직접 검수했다면 integratedFrom='${hint}'로 연결을 명시하고 그 검수 근거를 reason·evidenceLocators에 남기세요.`;
|
|
217
|
+
}
|
|
218
|
+
if (result.cause === "source-unobserved" && revision) {
|
|
219
|
+
const continuation = (args.attempt ?? 1) > 1 ? " 재작업·연속 attempt의 보고가 구조화 JSON이 아니면 revision을 읽지 못합니다." : "";
|
|
220
|
+
return `이 attempt의 terminal report revision이 미관측이라 검수 revision '${revision}'과의 관계를 unknown으로 기록했습니다. terminal report의 최상위 revision 문자열만 읽으며 원 revision을 추정해 채우지 않습니다.${continuation}`;
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* report 객체가 revision을 object로 낼 때 먼저 찾는 대표 문자열 필드(대소문자 무시). 실제 보고의 `HEAD`·`base`·`id` 모양을 따른다.
|
|
227
|
+
* 이 목록에 없는 object(파일 해시 묶음 등)는 아래 압축 JSON으로 읽는다.
|
|
228
|
+
*/
|
|
229
|
+
const REVISION_OBJECT_KEYS = ["id", "revision", "commit", "sha", "head", "base_head", "base"];
|
|
230
|
+
/** revision 대신 같은 뜻으로 쓰인 key. alembic_revision 같은 다른 뜻의 key는 넣지 않는다. */
|
|
231
|
+
const REVISION_ALIAS_KEYS = ["sourceRevision", "source_revision"];
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Maker terminal report 객체에서 원 revision 문자열을 읽는다. 순서: 최상위 `revision` 문자열 → revision object의
|
|
235
|
+
* 대표 필드 → `sourceRevision`·`source_revision` 별칭 → revision object 압축 JSON(500자까지).
|
|
236
|
+
* 압축 JSON은 object 안에 `*revision*` key가 하나라도 있으면 쓰지 않는다(alembic_revision 같은 뜻이 다른 값을 원 revision으로 읽지 않는다).
|
|
237
|
+
* 읽을 수 없으면 undefined이며 추정으로 채우지 않는다.
|
|
238
|
+
*/
|
|
239
|
+
export function pickReportRevision(record: Record<string, unknown>): string | undefined {
|
|
240
|
+
const str = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
|
|
241
|
+
const direct = str(record.revision);
|
|
242
|
+
if (direct) return direct;
|
|
243
|
+
const object = record.revision && typeof record.revision === "object" && !Array.isArray(record.revision)
|
|
244
|
+
? record.revision as Record<string, unknown> : undefined;
|
|
245
|
+
if (object) {
|
|
246
|
+
const lower = new Map(Object.entries(object).map(([key, value]) => [key.toLowerCase(), value]));
|
|
247
|
+
for (const key of REVISION_OBJECT_KEYS) { const found = str(lower.get(key)); if (found) return found; }
|
|
248
|
+
}
|
|
249
|
+
for (const key of REVISION_ALIAS_KEYS) { const found = str(record[key]); if (found) return found; }
|
|
250
|
+
if (object && !Object.keys(object).some((key) => /revision/i.test(key))) {
|
|
251
|
+
const json = JSON.stringify(object);
|
|
252
|
+
if (json !== "{}") return json.length > 500 ? json.slice(0, 500) : json;
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 한 알림에 task-result 블록이 jobs보다 적거나 순서가 다를 수 있다(블록 없는 재개 job이 섞인다).
|
|
259
|
+
* 먼저 블록의 id를 job의 jobId·agentId·label과 맞춘다. id로 못 맞춘 job은 id로 소비되지 않은 블록을 순서대로 받는다.
|
|
260
|
+
*/
|
|
261
|
+
export function assignTaskResultBlocks(
|
|
262
|
+
blocks: readonly string[],
|
|
263
|
+
jobs: ReadonlyArray<{ jobId: string; agentId?: string; label?: string }>,
|
|
264
|
+
): Array<string | undefined> {
|
|
265
|
+
const idOf = (block: string) => /<task-result\b[^>]*?\bid="([^"]*)"/i.exec(block)?.[1];
|
|
266
|
+
const used = new Set<number>();
|
|
267
|
+
const assigned = jobs.map((job) => {
|
|
268
|
+
const keys = [job.jobId, job.agentId, job.label].filter((key): key is string => Boolean(key));
|
|
269
|
+
for (const key of keys) {
|
|
270
|
+
const index = blocks.findIndex((block, i) => !used.has(i) && idOf(block) === key);
|
|
271
|
+
if (index >= 0) { used.add(index); return blocks[index]; }
|
|
272
|
+
}
|
|
273
|
+
return undefined;
|
|
274
|
+
});
|
|
275
|
+
const leftover = blocks.filter((_, i) => !used.has(i));
|
|
276
|
+
return assigned.map((block) => block ?? leftover.shift());
|
|
277
|
+
}
|
|
278
|
+
|
|
100
279
|
export type LedgerRecord = DispatchRecord | OutcomeRecord | VerdictRecord;
|
|
101
280
|
|
|
102
281
|
/**
|
|
@@ -7,6 +7,6 @@ description: 집·사무실 CUELO 배포 확인과 WSL 배포, 커밋 CI 감시,
|
|
|
7
7
|
`RULES.md`의 승인 경계(삭제·배포·비용 발생·호환성 파괴는 사전 승인)를 그대로 따르며, 아래는 그 안에서의 운영 절차다. 프로젝트 `AGENTS.md`의 상시 승인처럼 더 구체적인 사용자 결정이 있으면 그것이 우선한다.
|
|
8
8
|
|
|
9
9
|
- 배포 확인(사용자, 2026-09-26·2026-09-29·2026-09-30): 집 PC(hostname `KYS`)와 사무실 PC(hostname은 비공개 배포 표에 있다)는 같은 확인 절차를 따른다. 그 PC의 CUELO 세션이 소스 변경을 검증하고 커밋·push했으면, 그 커밋의 GitHub CI가 끝날 때까지 지켜본 뒤 결과를 보고하고 배포 여부를 묻는다. 사용자가 승인하면 같은 revision으로 아래 「WSL 배포」를 실행한다. 라이브 서버는 Settings를 시작 때 한 번만 읽어 파일 변경·touch로는 반영되지 않으므로, 배포 없이 프로필만 바꿨으면 지원되는 프로필 갱신 뒤 서버를 재시작하고 `/api/models` 역할을 확인한다. 실행 전 `hostname`으로 PC를 확인한다. 예약 작업 같은 자동 배포는 두지 않는다. stage·rollback 삭제, 이 두 PC 밖의 배포, CLI 업데이트, 비용이 드는 작업은 따로 승인받는다.
|
|
10
|
-
- WSL 배포(사용자, 2026-10-07): 두 PC 모두 WSL의 CUELO 하나로 돈다(집 배포판 `Ubuntu-24.04`, 사무실 `Ubuntu`). 재시작을 알리고 WSL에서 `systemd-run --user --unit=cuelo-update-$(date +%s) --collect --setenv=WSL_DISTRO_NAME="$WSL_DISTRO_NAME" bash ~/cuelo-run/Tools/CUELO_Setup/wsl/update.sh <revision
|
|
10
|
+
- WSL 배포(사용자, 2026-10-07): 두 PC 모두 WSL의 CUELO 하나로 돈다(집 배포판 `Ubuntu-24.04`, 사무실 `Ubuntu`). 재시작을 알리고 WSL에서 `systemd-run --user --unit=cuelo-update-$(date +%s) --collect --setenv=WSL_DISTRO_NAME="$WSL_DISTRO_NAME" bash ~/cuelo-run/Tools/CUELO_Setup/wsl/update.sh <revision> "$PI_SESSION_ID"`을 실행한다(Windows 세션이면 `wsl.exe -d <배포판> --exec bash -lc '…'`로 배포판 이름을 적어서). CUELO 세션 안에서는 두 번째 인자로 현재 세션 id를 꼭 넘긴다. 빠지면 drain이나 배포가 실패했을 때 깨우기가 `no-initiator`로 건너뛰어져 배포를 시작한 세션이 멈춘 채 남는다(2026-10-25). 세션 정지·빌드·WSL 프로필 반영·재시작·대기 화면 복귀까지 이 스크립트가 맡는다. `Tools/CUELO_Setup/deploy-live.ps1`과 앱 전체 `setup.ps1`은 지운 Windows 앱을 다시 설치하므로 두 PC에서 실행하지 않는다. `verify.ps1 -StrictRuntime`·`setup.ps1 -ProfileOnly`는 Windows 쪽 프로필·런타임을 보므로 WSL 배포의 증거가 아니다. 완료는 최신 `~/.omp/wsl-update/*.log`의 `READY <revision>`, `wsl\launch.ps1 -Status`의 `matched: true`(배포판이 기본값이 아니면 `-Distribution <배포판>`), `node install.mjs health` 4/4, `/api/models?cwd=<세션 cwd>` 역할 selector와 WSL `~/.omp/agent/config.yml` `modelRoles`의 일치로 본다(`cwd`가 없으면 403). exit 2(되돌림)·3(되돌리기 실패)은 배포 실패로 보고한다. 세부 절차와 함정은 `Tools/CUELO_Setup/WSL-SETUP.md` 「운영」에 있다.
|
|
11
11
|
- CI 감시(사용자, 2026-09-28·2026-10-03): 커밋의 CI를 지켜볼 때는 기다릴 워크플로를 이름으로 지정하고(`gh run list --workflow <이름>` 등), 그 run이 끝나는 즉시 결과를 보고한다. 같은 SHA에 걸린 run 전체의 완료를 조건으로 삼지 않는다. CI 성공이 트리거하는 릴리스·배포 run(`workflow_run`)이 몇 초 뒤 같은 SHA로 생겨 CI 완료 보고를 붙잡기 때문이다. 이어 붙는 run은 보고한 뒤 따로 지켜본다. 감시는 `bash`의 `gh run watch <id> --exit-status`나 `gh run list` 직접 조회로 하고, `eval` 커널 안에서 `gh`를 subprocess로 부르는 폴링 루프로 짜지 않는다. 2026-10-03 그런 루프가 빈 출력을 "run 없음"으로 읽어, CI가 끝난 뒤에도 20분 넘게 보고하지 못했다. 직접 짠 폴링은 빈 출력·0이 아닌 exit를 즉시 오류로 끝낸다. 공개 미러 CI는 headSha가 원본 커밋과 다르므로 run 제목으로 찾는다.
|
|
12
12
|
- WSL 작업 결과의 Windows 반영(사용자, 2026-10-11, 모든 세션): 대응하는 기존 Windows 프로젝트 사본이 확인된 WSL 작업은 완료할 때 검증·커밋·게시한 소스와 문서를 Windows 사본에도 같은 커밋으로 자동 반영한다. UNC로 같은 WSL 폴더를 여는 것은 이 반영을 대신하지 않는다. 대상은 프로젝트 운영 문서의 명시 대응 경로와 실제 Git origin·branch로 확인하며, 경로를 추정해 다른 프로젝트를 갱신하거나 새 사본을 만들지 않는다. Main이 `git_finalize`로 게시를 마친 뒤 `bun ~/.omp/agent/tools/git-finalizer/sync-checkout.ts --source <WSL 저장소 루트> --target <대응 Windows 사본의 /mnt 경로> --revision <게시한 전체 SHA>`를 호출한다. 명령은 수정 없는 동일 origin·branch의 사본만 정확한 커밋으로 fast-forward하고 결과를 확인한다. 미커밋 수정·미추적 파일·분기 이력·락·인증 실패는 보존하고 막힌 이유를 알린다. reset·stash·강제 덮어쓰기·자동 rebase로 우회하지 않는다. 원본 작업 완료와 Windows 반영 완료는 구분해 보고하며 실패를 성공으로 감추지 않는다. 실시간·양방향 동기화나 감시 작업은 만들지 않고, 실행 산출물·설치된 프로필·인증 DB·세션·기억·독립 CLI를 복사하지 않는다. WSL 단일 CUELO의 실행본·활성 하네스 프로필 갱신은 기존 WSL 배포 절차를 따르며 Windows 앱을 다시 설치하지 않는다. Windows 전용 MSBuild·VB6·COM·장비 프로젝트의 기존 위치와 도구를 임의로 바꾸지 않는다.
|
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
"main": "main-checks-that-evidence-and-resolves-the-makers-chain-instead-of-starting-a-duplicate-review-of-the-same-content-and-starts-a-new-round-only-when-content-changed-after-the-makers-last-round",
|
|
286
286
|
"gate": "git_finalize-stage-cross-review-is-unchanged"
|
|
287
287
|
},
|
|
288
|
-
"rounds": "at-most-five-per-chain-with-at-most-one-probe-round-and-at-most-four-non-probe-rounds-chosen-from-the-2026-10-23-ledger-measurement-where-64-of-69-multi-round-chains-used-both-old-non-probe-rounds-and-26-chains-ended-at-the-limit-with-findings-in-the-last-round-so-that-a-round-s-fixes-can-normally-be-reviewed-by-the-next-round-chains-recorded-under-the-old-limit-of-three-still-read-a-probe-round-is-any-round-submitted-with-non-empty-probeResults-even-when-it-also-carries-a-code-delta-a-non-probe-later-round-reviews-only-the-delta-since-the-latest-content-the-reviewer-was-shown-and-is-refused-when-nothing-changed-a-probe-round-carries-the-results-of-experiments-the-reviewer-proposed-through-finding-probe-may-have-no-code-change-and-returns-a-confirmed-withdrawn-or-unresolved-verdict-per-submitted-result-that-is-advisory-only-and-never-closes-a-finding-resolve-reports-files-whose-content-differs-from-the-last-reviewed-round-as-unreviewedChanges-
|
|
288
|
+
"rounds": "at-most-five-per-chain-with-at-most-one-probe-round-and-at-most-four-non-probe-rounds-chosen-from-the-2026-10-23-ledger-measurement-where-64-of-69-multi-round-chains-used-both-old-non-probe-rounds-and-26-chains-ended-at-the-limit-with-findings-in-the-last-round-so-that-a-round-s-fixes-can-normally-be-reviewed-by-the-next-round-chains-recorded-under-the-old-limit-of-three-still-read-a-probe-round-is-any-round-submitted-with-non-empty-probeResults-even-when-it-also-carries-a-code-delta-a-non-probe-later-round-reviews-only-the-delta-since-the-latest-content-the-reviewer-was-shown-and-is-refused-when-nothing-changed-a-probe-round-carries-the-results-of-experiments-the-reviewer-proposed-through-finding-probe-may-have-no-code-change-and-returns-a-confirmed-withdrawn-or-unresolved-verdict-per-submitted-result-that-is-advisory-only-and-never-closes-a-finding-resolve-reports-files-whose-content-differs-from-the-last-reviewed-round-as-unreviewedChanges-and-a-code-file-among-them-blocks-the-pass-as-unreviewedBlocking-until-a-later-round-shows-it-or-when-no-non-probe-round-is-left-or-the-reviewer-can-never-be-shown-that-path-a-per-path-unreviewedAcknowledgement-with-non-empty-evidence-is-recorded-while-document-test-and-generated-files-stay-advisory-because-the-2026-10-11-to-10-24-ledger-had-30-of-155-passing-resolves-covering-code-the-reviewer-never-saw",
|
|
289
289
|
"committedChange": "the-optional-first-round-base-is-head-or-an-ancestor-commit-and-shows-the-change-from-it-to-head-only-for-files-whose-working-tree-equals-head-so-an-already-committed-change-can-be-reviewed-without-a-scratch-worktree-and-the-base-diff-can-never-hide-part-of-the-head-relative-change-it-never-reaches-the-gate-whose-coverage-still-binds-exact-blobs-and-a-later-round-continues-the-chain-with-previousReviewId-without-base",
|
|
290
290
|
"gate": "git_finalize-stage-cross-review-runs-before-the-commit-and-blocks-a-required-change-without-a-passing-record-that-still-covers-it-including-the-inputs-that-record-was-bound-to",
|
|
291
291
|
"waiver": "only-on-an-explicit-user-instruction-through-crossReviewWaiver-and-recorded-in-the-ledger-never-chosen-by-main-or-a-maker-on-its-own",
|
|
@@ -634,8 +634,10 @@ evidence locator·이유를 남긴다. `held`는 같은 identity와 보류 이
|
|
|
634
634
|
통과는 모든
|
|
635
635
|
finding이 닫힌 상태이고 깨끗한 검토도 PASS 판정이 아니다. 한 체인은 최대 5회이며 probe 라운드는 그중 하나,
|
|
636
636
|
probe가 아닌 라운드는 넷이다(2026-10-23 실측에 맞춰 3·2에서 올렸고 옛 체인은 그대로 읽힌다). 수정은 닫기 전에
|
|
637
|
-
다음 라운드로 reviewer에게 다시 보인다.
|
|
638
|
-
|
|
637
|
+
다음 라운드로 reviewer에게 다시 보인다. 마지막 라운드 뒤에 바뀐 코드 파일이 남으면 finding이 모두 닫혀도 resolve는
|
|
638
|
+
통과시키지 않는다(`unreviewedBlocking`). 같은 체인의 다음 라운드로 보인 뒤 다시 resolve한다. 더 돌릴 라운드가 없거나
|
|
639
|
+
reviewer가 볼 수 없는 파일(바이너리·개인키·비밀 경로)만 `unreviewedAcknowledgements`에 경로별 검사 근거를 적어 통과하며,
|
|
640
|
+
문서·테스트·생성물의 미검토 수정은 안내만 한다. 이미 커밋된 변경은 1회차에 `base`(HEAD 또는 그 조상)를 주어 그 커밋부터 HEAD까지를 심사하고
|
|
639
641
|
(대상 파일의 작업 트리가 HEAD와 같아야 한다), 이어지는 라운드는
|
|
640
642
|
`previousReviewId`만으로 이어 간다. probe가 아닌 후속 라운드는 직전에 reviewer가 본 내용 이후의 delta만 본다.
|
|
641
643
|
reviewer는 코드만으로 가릴 수 없는 의심에 finding의 `probe`(재현 절차와 `expectIfReal`)를 붙일 수 있다.
|
package/bin/cuelo.js
CHANGED
|
@@ -51,6 +51,8 @@ Usage: cuelo [options]
|
|
|
51
51
|
cuelo setup [--home <dir>] [--model <provider/model>] [--role <name>=<provider/model[:effort]>]
|
|
52
52
|
cuelo start [--home <dir>]
|
|
53
53
|
cuelo health
|
|
54
|
+
cuelo harness-diff [--home <dir>] [--json]
|
|
55
|
+
cuelo harness-update [--home <dir>] [--dry-run] [--json]
|
|
54
56
|
|
|
55
57
|
Options:
|
|
56
58
|
-p, --port <port> Server port (default: 30141)
|
|
@@ -66,7 +68,7 @@ If install scripts did not run, use \"npm run prepare:runtime\" from the install
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
// 설치·서비스 묶음 명령은 공개 install.mjs가 소유한다. 인자·신호·종료 코드를 전달한다.
|
|
69
|
-
if (["setup", "start", "health"].includes(process.argv[2])) {
|
|
71
|
+
if (["setup", "start", "health", "harness-diff", "harness-update"].includes(process.argv[2])) {
|
|
70
72
|
const child = spawn(process.execPath, [path.join(__dirname, "..", "install.mjs"), ...process.argv.slice(2)], {
|
|
71
73
|
cwd: path.join(__dirname, ".."),
|
|
72
74
|
stdio: "inherit",
|
package/install.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// node install.mjs [setup] [--home <dir>] [--model <provider/model>] [--role <name>=<provider/model[:effort]>]...
|
|
5
5
|
// node install.mjs start [--home <dir>] [--port-base <n>]
|
|
6
6
|
// node install.mjs health [--port-base <n>]
|
|
7
|
+
// node install.mjs harness-diff [--home <dir>] [--json]
|
|
8
|
+
// node install.mjs harness-update [--home <dir>] [--dry-run] [--json]
|
|
7
9
|
//
|
|
8
10
|
// setup: in a source checkout installs dependencies, builds, applies and checks
|
|
9
11
|
// the SDK patches (same steps as ci.yml and bin/prepare-runtime.js); in an
|
|
@@ -18,6 +20,7 @@ import { createRequire } from "node:module";
|
|
|
18
20
|
import os from "node:os";
|
|
19
21
|
import path from "node:path";
|
|
20
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
+
import crypto from "node:crypto";
|
|
21
24
|
|
|
22
25
|
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
23
26
|
const require = createRequire(path.join(ROOT, "package.json"));
|
|
@@ -81,17 +84,25 @@ class UsageError extends Error {}
|
|
|
81
84
|
export function parseArgs(argv) {
|
|
82
85
|
const args = [...argv];
|
|
83
86
|
const command = args[0] && !args[0].startsWith("--") ? args.shift() : "setup";
|
|
84
|
-
if (!["setup", "start", "health"].includes(command)) throw new UsageError(`unknown command: ${command}`);
|
|
85
|
-
const options = { command, home: null, roles: {}, portBase: null, hostname: "127.0.0.1" };
|
|
87
|
+
if (!["setup", "start", "health", "harness-diff", "harness-update"].includes(command)) throw new UsageError(`unknown command: ${command}`);
|
|
88
|
+
const options = { command, home: null, roles: {}, portBase: null, hostname: "127.0.0.1", json: false, dryRun: false };
|
|
86
89
|
while (args.length > 0) {
|
|
87
90
|
const flag = args.shift();
|
|
91
|
+
if (flag === "--json" && command.startsWith("harness-")) {
|
|
92
|
+
options.json = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (flag === "--dry-run" && command === "harness-update") {
|
|
96
|
+
options.dryRun = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
88
99
|
const value = args.shift();
|
|
89
100
|
if (value === undefined || value.startsWith("--")) throw new UsageError(`${flag} needs a value`);
|
|
90
101
|
if (flag === "--home" && command !== "health") {
|
|
91
102
|
options.home = path.resolve(value);
|
|
92
103
|
} else if (flag === "--hostname" && command === "start") {
|
|
93
104
|
options.hostname = value;
|
|
94
|
-
} else if (flag === "--port-base" && command
|
|
105
|
+
} else if (flag === "--port-base" && (command === "start" || command === "health")) {
|
|
95
106
|
options.portBase = parsePort(value, MAX_PORT_BASE);
|
|
96
107
|
if (options.portBase === null) throw new UsageError(`--port-base takes a number from 1 to ${MAX_PORT_BASE}`);
|
|
97
108
|
} else if (flag === "--model" && command === "setup") {
|
|
@@ -131,33 +142,273 @@ export function harnessEntries(packageFiles) {
|
|
|
131
142
|
.map((entry) => entry.slice(HARNESS_PREFIX.length));
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
/**
|
|
135
|
-
export function
|
|
136
|
-
const
|
|
137
|
-
const
|
|
145
|
+
/** Every distributed harness file as a forward-slash path relative to the agent folder, in walk order. */
|
|
146
|
+
export function listHarnessFiles(sourceDir, entries) {
|
|
147
|
+
const files = [];
|
|
148
|
+
const walk = (rel) => {
|
|
138
149
|
const name = path.basename(rel);
|
|
139
150
|
// Same exclusions as the `!…/**/tests` and `!…/**/*.test.*` entries of `files`; npm
|
|
140
151
|
// never packs a nested node_modules either.
|
|
141
152
|
if (name === "tests" || name === "node_modules" || /\.test\./.test(name)) return;
|
|
142
153
|
const source = path.join(sourceDir, rel);
|
|
143
154
|
if (fs.statSync(source).isDirectory()) {
|
|
144
|
-
for (const child of fs.readdirSync(source))
|
|
155
|
+
for (const child of fs.readdirSync(source)) walk(path.join(rel, child));
|
|
145
156
|
return;
|
|
146
157
|
}
|
|
147
|
-
|
|
158
|
+
files.push(rel.split(path.sep).join("/"));
|
|
159
|
+
};
|
|
160
|
+
for (const entry of entries) walk(entry);
|
|
161
|
+
return files;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Copies the public harness file by file; an existing profile file is always kept as it is. */
|
|
165
|
+
export function copyHarness(sourceDir, agentDir, entries) {
|
|
166
|
+
const report = { created: [], kept: [] };
|
|
167
|
+
for (const rel of listHarnessFiles(sourceDir, entries)) {
|
|
168
|
+
const target = path.join(agentDir, ...rel.split("/"));
|
|
148
169
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
149
170
|
try {
|
|
150
|
-
fs.copyFileSync(
|
|
171
|
+
fs.copyFileSync(path.join(sourceDir, ...rel.split("/")), target, fs.constants.COPYFILE_EXCL);
|
|
151
172
|
report.created.push(rel);
|
|
152
173
|
} catch (error) {
|
|
153
174
|
if (error.code !== "EEXIST") throw error;
|
|
154
175
|
report.kept.push(rel);
|
|
155
176
|
}
|
|
156
|
-
}
|
|
157
|
-
for (const entry of entries) copy(entry);
|
|
177
|
+
}
|
|
158
178
|
return report;
|
|
159
179
|
}
|
|
160
180
|
|
|
181
|
+
export const HARNESS_MANIFEST = ".cuelo-harness-installed.json";
|
|
182
|
+
const HARNESS_BACKUP_DIR = ".cuelo-harness-backup";
|
|
183
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
184
|
+
|
|
185
|
+
function hashBytes(bytes) {
|
|
186
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function fileHash(file) {
|
|
190
|
+
return hashBytes(fs.readFileSync(file));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* What setup / harness-update installed: relative path -> sha256 of the bytes it copied, or null for a
|
|
195
|
+
* file that already existed and so is not ours to update. Absent file = nothing recorded; an unreadable
|
|
196
|
+
* one throws, because treating it as empty and rewriting it would destroy what it said.
|
|
197
|
+
*/
|
|
198
|
+
export function readHarnessManifest(agentDir) {
|
|
199
|
+
const file = path.join(agentDir, HARNESS_MANIFEST);
|
|
200
|
+
if (!fs.existsSync(file)) return { cuelo: null, files: {} };
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw new Error(`${file} is not readable (${error.message}). Delete it to treat every harness file as untracked.`);
|
|
206
|
+
}
|
|
207
|
+
const files = {};
|
|
208
|
+
for (const [rel, hash] of Object.entries(parsed?.files ?? {})) files[rel] = typeof hash === "string" && SHA256.test(hash) ? hash : null;
|
|
209
|
+
return { cuelo: typeof parsed?.cuelo === "string" ? parsed.cuelo : null, files };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function writeHarnessManifest(agentDir, files, version) {
|
|
213
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
214
|
+
const file = path.join(agentDir, HARNESS_MANIFEST);
|
|
215
|
+
const sorted = Object.fromEntries(Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
216
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
217
|
+
fs.writeFileSync(temp, `${JSON.stringify({ version: 1, cuelo: version, files: sorted }, null, 2)}\n`);
|
|
218
|
+
fs.renameSync(temp, file);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Records a setup run: files copied now get their hash, files that were already there stay as they were
|
|
223
|
+
* recorded (or null). An existing record that cannot be read is left exactly as it is and nothing is
|
|
224
|
+
* recorded (the returned `skipped` says why); setup prints that as a warning and carries on.
|
|
225
|
+
*/
|
|
226
|
+
export function recordHarnessInstall(agentDir, report, version) {
|
|
227
|
+
let previous;
|
|
228
|
+
try {
|
|
229
|
+
previous = readHarnessManifest(agentDir).files;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return { skipped: error.message };
|
|
232
|
+
}
|
|
233
|
+
const files = {};
|
|
234
|
+
for (const rel of report.kept) files[rel] = previous[rel] ?? null;
|
|
235
|
+
for (const rel of report.created) files[rel] = fileHash(path.join(agentDir, ...rel.split("/")));
|
|
236
|
+
writeHarnessManifest(agentDir, files, version);
|
|
237
|
+
return { skipped: null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** False when a folder between the profile and the file is a link or not a folder; a path that does not exist yet is fine. */
|
|
241
|
+
function isProfilePathSafe(agentDir, rel) {
|
|
242
|
+
let dir;
|
|
243
|
+
try {
|
|
244
|
+
dir = fs.realpathSync(agentDir);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error.code === "ENOENT") return true;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
for (const part of rel.split("/").slice(0, -1)) {
|
|
250
|
+
dir = path.join(dir, part);
|
|
251
|
+
const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
252
|
+
if (!stat) return true;
|
|
253
|
+
if (!stat.isDirectory()) return false;
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const HARNESS_STATES = ["identical", "missing", "update-available", "user-modified", "untracked"];
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Compares the profile with the distributed harness and the install record.
|
|
262
|
+
* missing not in the profile: would be added
|
|
263
|
+
* identical same bytes as the distribution
|
|
264
|
+
* update-available still the bytes we installed, the distribution has changed
|
|
265
|
+
* user-modified differs from the bytes we installed
|
|
266
|
+
* untracked exists with no install record (or is not a plain file): never updated
|
|
267
|
+
*/
|
|
268
|
+
export function diffHarness(sourceDir, agentDir, entries) {
|
|
269
|
+
const manifest = readHarnessManifest(agentDir);
|
|
270
|
+
const files = listHarnessFiles(sourceDir, entries).sort().map((rel) => {
|
|
271
|
+
const target = path.join(agentDir, ...rel.split("/"));
|
|
272
|
+
const distHash = fileHash(path.join(sourceDir, ...rel.split("/")));
|
|
273
|
+
const recorded = manifest.files[rel] ?? null;
|
|
274
|
+
// The user's own arrangement (a linked folder or file, a folder in place of a file) is never written through:
|
|
275
|
+
// a write could land outside the profile.
|
|
276
|
+
if (!isProfilePathSafe(agentDir, rel)) return { path: rel, status: "untracked", distHash, profileHash: null, recorded };
|
|
277
|
+
const stat = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
278
|
+
if (!stat) return { path: rel, status: "missing", distHash, profileHash: null, recorded };
|
|
279
|
+
if (!stat.isFile()) return { path: rel, status: "untracked", distHash, profileHash: null, recorded };
|
|
280
|
+
const profileHash = fileHash(target);
|
|
281
|
+
const status = profileHash === distHash ? "identical"
|
|
282
|
+
: recorded === null ? "untracked"
|
|
283
|
+
: profileHash === recorded ? "update-available" : "user-modified";
|
|
284
|
+
return { path: rel, status, distHash, profileHash, recorded };
|
|
285
|
+
});
|
|
286
|
+
const counts = Object.fromEntries(HARNESS_STATES.map((state) => [state, files.filter((file) => file.status === state).length]));
|
|
287
|
+
return { installedVersion: manifest.cuelo, manifest, files, counts };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Brings the profile up to the distribution for the two safe states only: `update-available` files are
|
|
292
|
+
* backed up and overwritten, `missing` files are added. Everything else, config.yml included, is not touched.
|
|
293
|
+
* The plan comes from `diffHarness`, but a profile file can change between that read and the write, so each
|
|
294
|
+
* write re-checks first: an overwrite happens only if the bytes read right now still hash to the recorded
|
|
295
|
+
* installed hash (the backup is made from those same bytes), and an addition never replaces a file that
|
|
296
|
+
* appeared meanwhile. A file that fails its re-check is reported as user-modified / untracked instead.
|
|
297
|
+
*/
|
|
298
|
+
export function updateHarness(sourceDir, agentDir, entries, version, { dryRun = false, now = new Date() } = {}) {
|
|
299
|
+
const diff = diffHarness(sourceDir, agentDir, entries);
|
|
300
|
+
const planned = (status) => diff.files.filter((file) => file.status === status);
|
|
301
|
+
const skipped = {
|
|
302
|
+
"user-modified": planned("user-modified").map((file) => file.path),
|
|
303
|
+
untracked: planned("untracked").map((file) => file.path),
|
|
304
|
+
"backup-unsafe": [],
|
|
305
|
+
};
|
|
306
|
+
const backupStamp = now.toISOString().replace(/[:.]/g, "-");
|
|
307
|
+
let backupDir = null;
|
|
308
|
+
const updated = [];
|
|
309
|
+
const added = [];
|
|
310
|
+
if (dryRun) {
|
|
311
|
+
updated.push(...planned("update-available").map((file) => file.path));
|
|
312
|
+
added.push(...planned("missing").map((file) => file.path));
|
|
313
|
+
} else {
|
|
314
|
+
for (const file of planned("update-available")) {
|
|
315
|
+
const target = path.join(agentDir, ...file.path.split("/"));
|
|
316
|
+
let current = null;
|
|
317
|
+
if (isProfilePathSafe(agentDir, file.path) && fs.lstatSync(target, { throwIfNoEntry: false })?.isFile()) current = fs.readFileSync(target);
|
|
318
|
+
if (current === null) skipped.untracked.push(file.path);
|
|
319
|
+
else if (hashBytes(current) !== file.recorded) skipped["user-modified"].push(file.path);
|
|
320
|
+
else if (!isProfilePathSafe(agentDir, `${HARNESS_BACKUP_DIR}/${backupStamp}/${file.path}`)) skipped["backup-unsafe"].push(file.path);
|
|
321
|
+
else {
|
|
322
|
+
backupDir ??= path.join(agentDir, HARNESS_BACKUP_DIR, backupStamp);
|
|
323
|
+
const backup = path.join(backupDir, ...file.path.split("/"));
|
|
324
|
+
fs.mkdirSync(path.dirname(backup), { recursive: true });
|
|
325
|
+
fs.writeFileSync(backup, current, { flag: "wx" });
|
|
326
|
+
fs.copyFileSync(path.join(sourceDir, ...file.path.split("/")), target);
|
|
327
|
+
updated.push(file.path);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const file of planned("missing")) {
|
|
331
|
+
const target = path.join(agentDir, ...file.path.split("/"));
|
|
332
|
+
if (!isProfilePathSafe(agentDir, file.path)) {
|
|
333
|
+
skipped.untracked.push(file.path);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
337
|
+
try {
|
|
338
|
+
fs.copyFileSync(path.join(sourceDir, ...file.path.split("/")), target, fs.constants.COPYFILE_EXCL);
|
|
339
|
+
added.push(file.path);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error.code !== "EEXIST") throw error;
|
|
342
|
+
skipped.untracked.push(file.path);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Records follow what is on disk now: written files and identical tracked files carry the distributed
|
|
346
|
+
// hash, every other file keeps the record it had.
|
|
347
|
+
const written = new Set([...updated, ...added]);
|
|
348
|
+
const files = {};
|
|
349
|
+
for (const file of diff.files) {
|
|
350
|
+
const tracked = file.status === "identical" && file.recorded !== null;
|
|
351
|
+
files[file.path] = written.has(file.path) || tracked ? file.distHash : file.recorded;
|
|
352
|
+
}
|
|
353
|
+
writeHarnessManifest(agentDir, files, version);
|
|
354
|
+
}
|
|
355
|
+
return { dryRun, updated, added, skipped, backupDir, identical: diff.counts.identical };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function packageInfo() {
|
|
359
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function harnessSource() {
|
|
363
|
+
return {
|
|
364
|
+
sourceDir: path.join(ROOT, "Tools", "OMP_Global_Config", "agent"),
|
|
365
|
+
entries: harnessEntries(packageInfo().files ?? []),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function printList(label, files) {
|
|
370
|
+
if (files.length === 0) return;
|
|
371
|
+
console.log(`${label} (${files.length}):`);
|
|
372
|
+
for (const file of files) console.log(` ${file}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function harnessDiff(options) {
|
|
376
|
+
const { agentDir } = resolveProfile(options.home);
|
|
377
|
+
const { sourceDir, entries } = harnessSource();
|
|
378
|
+
const diff = diffHarness(sourceDir, agentDir, entries);
|
|
379
|
+
if (options.json) {
|
|
380
|
+
const files = diff.files.map(({ path: file, status }) => ({ path: file, status }));
|
|
381
|
+
console.log(JSON.stringify({ agentDir, cuelo: packageInfo().version, installedVersion: diff.installedVersion, counts: diff.counts, files }, null, 2));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const by = (state) => diff.files.filter((file) => file.status === state).map((file) => file.path);
|
|
385
|
+
console.log(`Harness in ${agentDir} against CUELO ${packageInfo().version} (installed by CUELO ${diff.installedVersion ?? "unknown"}):`);
|
|
386
|
+
printList("update-available: unmodified, a newer version is distributed; `harness-update` replaces it", by("update-available"));
|
|
387
|
+
printList("missing: not in the profile; `harness-update` adds it", by("missing"));
|
|
388
|
+
printList("user-modified: differs from what was installed; never changed automatically", by("user-modified"));
|
|
389
|
+
printList("untracked: exists with no install record; never changed automatically", by("untracked"));
|
|
390
|
+
console.log(`${diff.counts.identical} files identical to the distribution.`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function harnessUpdate(options) {
|
|
394
|
+
const { agentDir } = resolveProfile(options.home);
|
|
395
|
+
const { sourceDir, entries } = harnessSource();
|
|
396
|
+
const result = updateHarness(sourceDir, agentDir, entries, packageInfo().version, { dryRun: options.dryRun });
|
|
397
|
+
if (options.json) {
|
|
398
|
+
console.log(JSON.stringify({ agentDir, ...result }, null, 2));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const prefix = result.dryRun ? "Would be " : "";
|
|
402
|
+
printList(`${prefix}updated`, result.updated);
|
|
403
|
+
printList(`${prefix}added`, result.added);
|
|
404
|
+
printList("Left as it is, user-modified", result.skipped["user-modified"]);
|
|
405
|
+
printList("Left as it is, untracked", result.skipped.untracked);
|
|
406
|
+
printList("Left as it is, the backup folder inside the profile is a link or not a folder", result.skipped["backup-unsafe"]);
|
|
407
|
+
if (result.updated.length + result.added.length === 0) console.log("Nothing to update.");
|
|
408
|
+
if (result.backupDir) console.log(`Previous versions of the updated files were saved under ${result.backupDir}.`);
|
|
409
|
+
if (result.dryRun) console.log("Dry run: no file was changed.");
|
|
410
|
+
}
|
|
411
|
+
|
|
161
412
|
/**
|
|
162
413
|
* What a new profile's `config.yml` starts with, so the public harness runs the way it is used:
|
|
163
414
|
* local learning and project-scoped memory, the harness's Maker routing (isolated task runs,
|
|
@@ -295,9 +546,11 @@ function prepareRuntime() {
|
|
|
295
546
|
function setup(options) {
|
|
296
547
|
prepareRuntime();
|
|
297
548
|
const { agentDir } = resolveProfile(options.home);
|
|
298
|
-
const
|
|
299
|
-
const harness = copyHarness(path.join(ROOT, "Tools", "OMP_Global_Config", "agent"), agentDir, harnessEntries(
|
|
300
|
-
|
|
549
|
+
const pkg = packageInfo();
|
|
550
|
+
const harness = copyHarness(path.join(ROOT, "Tools", "OMP_Global_Config", "agent"), agentDir, harnessEntries(pkg.files ?? []));
|
|
551
|
+
const recorded = recordHarnessInstall(agentDir, harness, pkg.version);
|
|
552
|
+
if (recorded.skipped) console.log(`Warning: the harness install record was left unchanged because it cannot be read (${recorded.skipped}). harness-diff and harness-update refuse to run until you fix or delete it.`);
|
|
553
|
+
console.log(`Harness: ${harness.created.length} files added to ${agentDir}, ${harness.kept.length} existing files kept unchanged. Later: \`node install.mjs harness-diff\` shows how the profile differs from this version.`);
|
|
301
554
|
const roles = createProfileConfig(agentDir, options.roles);
|
|
302
555
|
if (roles.written) console.log(`Profile config: wrote ${roles.configPath}.`);
|
|
303
556
|
if (roles.ignored.length > 0) console.log(`Profile config: ${roles.configPath} already exists and was not changed; ignored ${roles.ignored.join(", ")}.`);
|
|
@@ -402,7 +655,7 @@ async function main(argv) {
|
|
|
402
655
|
} catch (error) {
|
|
403
656
|
if (!(error instanceof UsageError)) throw error;
|
|
404
657
|
console.error(error.message);
|
|
405
|
-
console.error("Usage: node install.mjs [setup|start|health] [--home <dir>] [--model <provider/model>] [--role <name>=<provider/model[:effort]>] [--port-base <n>] [--hostname <address>]");
|
|
658
|
+
console.error("Usage: node install.mjs [setup|start|health|harness-diff|harness-update] [--home <dir>] [--model <provider/model>] [--role <name>=<provider/model[:effort]>] [--port-base <n>] [--hostname <address>] [--json] [--dry-run]");
|
|
406
659
|
return 2;
|
|
407
660
|
}
|
|
408
661
|
try {
|
|
@@ -410,6 +663,14 @@ async function main(argv) {
|
|
|
410
663
|
setup(options);
|
|
411
664
|
return 0;
|
|
412
665
|
}
|
|
666
|
+
if (options.command === "harness-diff") {
|
|
667
|
+
harnessDiff(options);
|
|
668
|
+
return 0;
|
|
669
|
+
}
|
|
670
|
+
if (options.command === "harness-update") {
|
|
671
|
+
harnessUpdate(options);
|
|
672
|
+
return 0;
|
|
673
|
+
}
|
|
413
674
|
if (options.command === "start") return await start(options);
|
|
414
675
|
const results = await checkHealth(servicePorts(options.portBase));
|
|
415
676
|
for (const result of results) {
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|