trackops 2.0.5 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -695
- package/bin/trackops.js +52 -23
- package/lib/cli-format.js +118 -0
- package/lib/config.js +277 -44
- package/lib/control.js +1052 -352
- package/lib/env.js +40 -28
- package/lib/i18n.js +5 -4
- package/lib/init.js +194 -56
- package/lib/opera-bootstrap.js +326 -106
- package/lib/opera-phase-dod.js +485 -0
- package/lib/opera.js +243 -78
- package/lib/plans.js +1329 -0
- package/lib/quality-assert.js +49 -0
- package/lib/quality.js +1759 -0
- package/lib/release.js +18 -11
- package/lib/server.js +504 -192
- package/lib/skills.js +43 -35
- package/lib/workspace.js +32 -21
- package/locales/en.json +431 -75
- package/locales/es.json +432 -76
- package/package.json +6 -5
- package/scripts/quality-unit-tests.js +130 -0
- package/scripts/smoke-tests.js +438 -96
- package/skills/trackops/skill.json +29 -29
- package/templates/skills/opera-quality-guard/SKILL.md +26 -0
- package/templates/skills/opera-quality-guard/locales/en/SKILL.md +26 -0
- package/templates/skills/opera-skill/SKILL.md +8 -0
- package/templates/skills/opera-skill/locales/en/SKILL.md +8 -0
- package/ui/js/api.js +93 -26
- package/ui/js/app.js +13 -7
- package/ui/js/filters.js +49 -29
- package/ui/js/time-tracker.js +41 -28
- package/ui/js/views/board.js +22 -14
- package/ui/js/views/dashboard.js +206 -49
- package/ui/js/views/execution.js +7 -3
- package/ui/js/views/plans.js +284 -0
- package/ui/js/views/scrum.js +25 -13
- package/ui/js/views/sidebar.js +9 -8
- package/ui/js/views/tasks.js +238 -134
package/lib/env.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const path = require("path");
|
|
5
|
-
const config = require("./config");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const config = require("./config");
|
|
6
|
+
const { t, setLocale } = require("./i18n");
|
|
6
7
|
|
|
7
8
|
const SERVICE_ENV_KEYS = {
|
|
8
9
|
OpenAI: ["OPENAI_API_KEY"],
|
|
@@ -184,7 +185,7 @@ function syncEnvironment(contextOrRoot, controlState, options = {}) {
|
|
|
184
185
|
return auditEnvironment(context, control);
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
function auditEnvironment(contextOrRoot, controlState) {
|
|
188
|
+
function auditEnvironment(contextOrRoot, controlState) {
|
|
188
189
|
const context = config.ensureContext(contextOrRoot);
|
|
189
190
|
const control = controlState || config.loadControl(context);
|
|
190
191
|
const envMeta = normalizeEnvironmentMeta(control, context);
|
|
@@ -206,30 +207,41 @@ function auditEnvironment(contextOrRoot, controlState) {
|
|
|
206
207
|
presentKeys,
|
|
207
208
|
missingKeys,
|
|
208
209
|
lastAuditAt: envMeta.lastAuditAt,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
console.log(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function initLocale(contextOrRoot) {
|
|
214
|
+
try {
|
|
215
|
+
const control = config.loadControl(config.ensureContext(contextOrRoot));
|
|
216
|
+
setLocale(config.getLocale(control));
|
|
217
|
+
} catch (_error) {
|
|
218
|
+
setLocale("es");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function cmdStatus(contextOrRoot) {
|
|
223
|
+
initLocale(contextOrRoot);
|
|
224
|
+
const audit = auditEnvironment(contextOrRoot);
|
|
225
|
+
console.log(t("env.status.title"));
|
|
226
|
+
console.log(t("env.status.rootEnv", { path: audit.files.rootEnv }));
|
|
227
|
+
console.log(t("env.status.example", { path: audit.files.rootExample }));
|
|
228
|
+
console.log(t("env.status.appBridge", { path: audit.files.appBridge }));
|
|
229
|
+
console.log(t("env.status.bridgeMode", { value: audit.bridgeMode }));
|
|
230
|
+
console.log(t("env.status.required", { value: audit.requiredKeys.length ? audit.requiredKeys.join(", ") : t("locale.none") }));
|
|
231
|
+
console.log(t("env.status.present", { value: audit.presentKeys.length ? audit.presentKeys.join(", ") : t("locale.none") }));
|
|
232
|
+
console.log(t("env.status.missing", { value: audit.missingKeys.length ? audit.missingKeys.join(", ") : t("locale.none") }));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function cmdSync(contextOrRoot) {
|
|
236
|
+
initLocale(contextOrRoot);
|
|
237
|
+
const context = config.ensureContext(contextOrRoot);
|
|
238
|
+
const control = config.loadControl(context);
|
|
239
|
+
const audit = syncEnvironment(context, control);
|
|
240
|
+
console.log(t("env.sync.updated", { path: path.relative(context.workspaceRoot, context.env.rootFile) }));
|
|
241
|
+
if (audit.missingKeys.length) {
|
|
242
|
+
console.log(t("env.sync.missing", { value: audit.missingKeys.join(", ") }));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
233
245
|
|
|
234
246
|
module.exports = {
|
|
235
247
|
SERVICE_ENV_KEYS,
|
package/lib/i18n.js
CHANGED
|
@@ -38,10 +38,11 @@ function getLocale() {
|
|
|
38
38
|
|
|
39
39
|
function t(key, params) {
|
|
40
40
|
ensureLoaded();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
key;
|
|
41
|
+
const found = currentMessages[key] || (fallbackMessages && fallbackMessages[key]);
|
|
42
|
+
let message = found || key;
|
|
43
|
+
if (!found && process.env.NODE_ENV !== "production" && process.env.TRACKOPS_DEBUG) {
|
|
44
|
+
process.stderr.write(`[i18n] Missing key: ${key}\n`);
|
|
45
|
+
}
|
|
45
46
|
if (params) {
|
|
46
47
|
message = message.replace(/\{(\w+)\}/g, (match, paramKey) =>
|
|
47
48
|
params[paramKey] !== undefined ? String(params[paramKey]) : match
|
package/lib/init.js
CHANGED
|
@@ -12,15 +12,36 @@ const { t, setLocale } = require("./i18n");
|
|
|
12
12
|
const { detectSystemLocale, promptForLocale, maybePromptForLocale, resolveLocale } = require("./locale");
|
|
13
13
|
const runtimeState = require("./runtime-state");
|
|
14
14
|
|
|
15
|
-
const GENERATED_SCRIPT_COMMANDS = {
|
|
15
|
+
const GENERATED_SCRIPT_COMMANDS = {
|
|
16
16
|
ops: "npx --yes trackops",
|
|
17
17
|
"ops:help": "npx --yes trackops help",
|
|
18
18
|
"ops:dashboard": "npx --yes trackops dashboard",
|
|
19
19
|
"ops:status": "npx --yes trackops status",
|
|
20
20
|
"ops:next": "npx --yes trackops next",
|
|
21
21
|
"ops:sync": "npx --yes trackops sync",
|
|
22
|
-
"ops:repo": "npx --yes trackops refresh-repo",
|
|
23
|
-
};
|
|
22
|
+
"ops:repo": "npx --yes trackops refresh-repo",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ROOT_PRESERVED_ENTRIES = new Set([
|
|
26
|
+
".git",
|
|
27
|
+
".gitignore",
|
|
28
|
+
".gitattributes",
|
|
29
|
+
".gitmodules",
|
|
30
|
+
".editorconfig",
|
|
31
|
+
".env",
|
|
32
|
+
".env.example",
|
|
33
|
+
".vscode",
|
|
34
|
+
".idea",
|
|
35
|
+
".nvmrc",
|
|
36
|
+
".node-version",
|
|
37
|
+
".tool-versions",
|
|
38
|
+
".npmrc",
|
|
39
|
+
".yarnrc.yml",
|
|
40
|
+
".pnp.cjs",
|
|
41
|
+
".pnp.loader.mjs",
|
|
42
|
+
"pnpm-workspace.yaml",
|
|
43
|
+
"turbo.json",
|
|
44
|
+
]);
|
|
24
45
|
|
|
25
46
|
function nowIso() {
|
|
26
47
|
return new Date().toISOString();
|
|
@@ -81,7 +102,7 @@ function buildDefaultControl(context, options) {
|
|
|
81
102
|
return {
|
|
82
103
|
meta: {
|
|
83
104
|
projectName: options.name || "My Project",
|
|
84
|
-
controlVersion:
|
|
105
|
+
controlVersion: config.DEFAULT_CONTROL_VERSION,
|
|
85
106
|
locale,
|
|
86
107
|
phases,
|
|
87
108
|
updatedAt: nowIso(),
|
|
@@ -112,12 +133,31 @@ function buildDefaultControl(context, options) {
|
|
|
112
133
|
explanationMode: null,
|
|
113
134
|
capturedAt: null,
|
|
114
135
|
},
|
|
115
|
-
discovery: {
|
|
116
|
-
projectState: null,
|
|
117
|
-
documentationState: null,
|
|
118
|
-
availableArtifacts: [],
|
|
119
|
-
},
|
|
120
|
-
|
|
136
|
+
discovery: {
|
|
137
|
+
projectState: null,
|
|
138
|
+
documentationState: null,
|
|
139
|
+
availableArtifacts: [],
|
|
140
|
+
},
|
|
141
|
+
quality: {
|
|
142
|
+
baselineProfile: "baseline",
|
|
143
|
+
activeProfiles: [],
|
|
144
|
+
verification: {
|
|
145
|
+
testCommands: [],
|
|
146
|
+
buildCommands: [],
|
|
147
|
+
smokeCommands: [],
|
|
148
|
+
reviewRequired: false,
|
|
149
|
+
},
|
|
150
|
+
lastReportAt: null,
|
|
151
|
+
lastVerificationAt: null,
|
|
152
|
+
lastReleaseReadiness: null,
|
|
153
|
+
lastPromotionReadiness: null,
|
|
154
|
+
},
|
|
155
|
+
agentInbox: {
|
|
156
|
+
pending: [],
|
|
157
|
+
history: [],
|
|
158
|
+
lastIssuedAt: null,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
121
161
|
checks: {
|
|
122
162
|
lastBuild: { status: "pending", date: null, note: "" },
|
|
123
163
|
lastTest: { status: "pending", date: null, note: "" },
|
|
@@ -132,19 +172,30 @@ function buildDefaultControl(context, options) {
|
|
|
132
172
|
milestones: [],
|
|
133
173
|
decisionsPending: [],
|
|
134
174
|
tasks: [
|
|
135
|
-
{
|
|
136
|
-
id: "ops-bootstrap",
|
|
137
|
-
title: t("init.defaultTaskTitle"),
|
|
175
|
+
{
|
|
176
|
+
id: "ops-bootstrap",
|
|
177
|
+
title: t("init.defaultTaskTitle"),
|
|
138
178
|
phase: phases[0]?.id || "O",
|
|
139
179
|
stream: "Operations",
|
|
140
180
|
priority: "P0",
|
|
141
181
|
status: "pending",
|
|
142
182
|
required: true,
|
|
143
183
|
dependsOn: [],
|
|
144
|
-
summary: t("init.defaultTaskSummary"),
|
|
145
|
-
acceptance: [],
|
|
146
|
-
|
|
147
|
-
|
|
184
|
+
summary: t("init.defaultTaskSummary"),
|
|
185
|
+
acceptance: [],
|
|
186
|
+
execution: {
|
|
187
|
+
owner: config.DEFAULT_EXECUTION_OWNER,
|
|
188
|
+
lastActor: "system",
|
|
189
|
+
lastSource: "trackops_init",
|
|
190
|
+
currentSessionId: null,
|
|
191
|
+
lastSessionId: null,
|
|
192
|
+
lastSessionStatus: null,
|
|
193
|
+
awaitingUserConfirmation: false,
|
|
194
|
+
verificationPending: false,
|
|
195
|
+
updatedAt: nowIso(),
|
|
196
|
+
},
|
|
197
|
+
history: [{ at: nowIso(), action: "create", note: "trackops init" }],
|
|
198
|
+
},
|
|
148
199
|
],
|
|
149
200
|
findings: [],
|
|
150
201
|
};
|
|
@@ -176,41 +227,117 @@ function installHooks(context) {
|
|
|
176
227
|
|
|
177
228
|
if (fs.existsSync(path.join(context.workspaceRoot, ".git"))) {
|
|
178
229
|
const hooksPath = context.layout === "split" ? "ops/.githooks" : ".githooks";
|
|
179
|
-
spawnSync("git", ["config", "core.hooksPath", hooksPath], { cwd: context.workspaceRoot, encoding: "utf8" });
|
|
230
|
+
const result = spawnSync("git", ["config", "core.hooksPath", hooksPath], { cwd: context.workspaceRoot, encoding: "utf8" });
|
|
231
|
+
if (result.status !== 0) {
|
|
232
|
+
console.log(t("cli.hooksError"));
|
|
233
|
+
}
|
|
180
234
|
}
|
|
181
235
|
}
|
|
182
236
|
|
|
183
|
-
function ensureTmpDir(context) {
|
|
237
|
+
function ensureTmpDir(context) {
|
|
184
238
|
fs.mkdirSync(context.paths.tmpDir, { recursive: true });
|
|
185
239
|
const gitkeep = path.join(context.paths.tmpDir, ".gitkeep");
|
|
186
240
|
if (!fs.existsSync(gitkeep)) {
|
|
187
241
|
fs.writeFileSync(gitkeep, "", "utf8");
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isRetryableMoveError(error) {
|
|
246
|
+
return ["EPERM", "EXDEV", "EBUSY", "ENOTEMPTY"].includes(error?.code);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function moveEntry(fromPath, toPath) {
|
|
250
|
+
if (!fs.existsSync(fromPath)) return;
|
|
251
|
+
fs.mkdirSync(path.dirname(toPath), { recursive: true });
|
|
252
|
+
try {
|
|
253
|
+
fs.renameSync(fromPath, toPath);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (!isRetryableMoveError(error)) throw error;
|
|
256
|
+
const stat = fs.statSync(fromPath);
|
|
257
|
+
if (stat.isDirectory()) {
|
|
258
|
+
fs.cpSync(fromPath, toPath, { recursive: true, force: true });
|
|
259
|
+
fs.rmSync(fromPath, { recursive: true, force: true });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
fs.copyFileSync(fromPath, toPath);
|
|
263
|
+
fs.rmSync(fromPath, { force: true });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function collectRootEntries(targetRoot) {
|
|
268
|
+
return fs.readdirSync(targetRoot, { withFileTypes: true })
|
|
269
|
+
.filter((entry) => ![".", ".."].includes(entry.name));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function analyzeSplitInit(targetRoot) {
|
|
273
|
+
const entries = collectRootEntries(targetRoot);
|
|
274
|
+
const manifestPath = path.join(targetRoot, config.WORKSPACE_MANIFEST);
|
|
275
|
+
const legacyControlPath = path.join(targetRoot, "project_control.json");
|
|
276
|
+
const hasManifest = fs.existsSync(manifestPath);
|
|
277
|
+
|
|
278
|
+
if (hasManifest) {
|
|
279
|
+
return { mode: "upgrade", entries };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (fs.existsSync(legacyControlPath)) {
|
|
283
|
+
throw new Error(t("init.error.legacyDetected"));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const conflicts = entries
|
|
287
|
+
.map((entry) => entry.name)
|
|
288
|
+
.filter((name) => [config.DEFAULT_APP_DIR, config.DEFAULT_OPS_DIR, config.WORKSPACE_MANIFEST].includes(name));
|
|
289
|
+
|
|
290
|
+
if (conflicts.length) {
|
|
291
|
+
throw new Error(t("init.error.reservedConflict", { entries: conflicts.join(", ") }));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const movableEntries = entries.filter((entry) => !ROOT_PRESERVED_ENTRIES.has(entry.name));
|
|
295
|
+
return {
|
|
296
|
+
mode: movableEntries.length ? "adopt" : "new",
|
|
297
|
+
entries,
|
|
298
|
+
movableEntries,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function adoptExistingProject(targetRoot, appRoot, movableEntries) {
|
|
303
|
+
for (const entry of movableEntries) {
|
|
304
|
+
moveEntry(path.join(targetRoot, entry.name), path.join(appRoot, entry.name));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function initSplitProject(root, options) {
|
|
309
|
+
const targetRoot = path.resolve(root);
|
|
310
|
+
fs.mkdirSync(targetRoot, { recursive: true });
|
|
311
|
+
const projectName = options.name || detectProjectName(targetRoot);
|
|
312
|
+
const initMode = analyzeSplitInit(targetRoot);
|
|
313
|
+
const manifest = workspace.buildManifest();
|
|
314
|
+
const context = config.createSplitContext(targetRoot, manifest);
|
|
315
|
+
|
|
316
|
+
if (initMode.mode === "adopt") {
|
|
317
|
+
fs.mkdirSync(context.appRoot, { recursive: true });
|
|
318
|
+
adoptExistingProject(targetRoot, context.appRoot, initMode.movableEntries);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
fs.mkdirSync(context.appRoot, { recursive: true });
|
|
322
|
+
fs.mkdirSync(context.opsRoot, { recursive: true });
|
|
323
|
+
config.saveWorkspaceManifest(context, manifest);
|
|
324
|
+
workspace.ensureRootGitignore(targetRoot);
|
|
325
|
+
|
|
326
|
+
const controlFile = context.controlFile || path.join(context.opsRoot, "project_control.json");
|
|
327
|
+
const isUpgrade = initMode.mode === "upgrade" && fs.existsSync(controlFile);
|
|
328
|
+
let control;
|
|
329
|
+
if (isUpgrade) {
|
|
330
|
+
control = JSON.parse(fs.readFileSync(controlFile, "utf8"));
|
|
331
|
+
if (!control.meta.phases) control.meta.phases = options.phases || config.buildDefaultPhases(options.locale);
|
|
332
|
+
if (!control.meta.locale) control.meta.locale = options.locale || config.DEFAULT_LOCALE;
|
|
333
|
+
if (!control.meta.controlVersion || control.meta.controlVersion < config.DEFAULT_CONTROL_VERSION) control.meta.controlVersion = config.DEFAULT_CONTROL_VERSION;
|
|
334
|
+
control.meta.updatedAt = nowIso();
|
|
335
|
+
config.saveControl(context, control);
|
|
336
|
+
} else {
|
|
337
|
+
control = buildDefaultControl(context, options);
|
|
338
|
+
control.meta.projectName = projectName;
|
|
339
|
+
config.saveControl(context, control);
|
|
340
|
+
}
|
|
214
341
|
|
|
215
342
|
installHooks(context);
|
|
216
343
|
ensureTmpDir(context);
|
|
@@ -224,16 +351,27 @@ function initSplitProject(root, options) {
|
|
|
224
351
|
// ignore
|
|
225
352
|
}
|
|
226
353
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
354
|
+
if (isUpgrade) {
|
|
355
|
+
console.log(t("init.updated", { file: ".trackops-workspace.json" }));
|
|
356
|
+
console.log(t("init.updated", { file: "ops/project_control.json" }));
|
|
357
|
+
console.log(t("init.updated", { file: "ops/.githooks/" }));
|
|
358
|
+
console.log(t("init.updated", { file: ".env" }));
|
|
359
|
+
console.log(t("init.updated", { file: ".env.example" }));
|
|
360
|
+
} else {
|
|
361
|
+
console.log(t("init.created", { file: ".trackops-workspace.json" }));
|
|
362
|
+
console.log(t("init.created", { file: "ops/project_control.json" }));
|
|
363
|
+
console.log(t("init.created", { file: "ops/.githooks/" }));
|
|
364
|
+
console.log(t("init.created", { file: ".env" }));
|
|
365
|
+
console.log(t("init.created", { file: ".env.example" }));
|
|
366
|
+
if (initMode.mode === "adopt") {
|
|
367
|
+
console.log(t("init.adoptedExistingRepo", { dir: path.basename(context.appRoot) }));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
console.log("");
|
|
371
|
+
console.log(t("init.welcome"));
|
|
372
|
+
|
|
373
|
+
return { root: context.workspaceRoot, context, isUpgrade, operaDetected: false };
|
|
374
|
+
}
|
|
237
375
|
|
|
238
376
|
function initLegacyProject(root, options) {
|
|
239
377
|
const targetRoot = path.resolve(root);
|
|
@@ -250,7 +388,7 @@ function initLegacyProject(root, options) {
|
|
|
250
388
|
const existing = JSON.parse(fs.readFileSync(controlFile, "utf8"));
|
|
251
389
|
if (!existing.meta.phases) existing.meta.phases = options.phases || config.buildDefaultPhases(options.locale);
|
|
252
390
|
if (!existing.meta.locale) existing.meta.locale = options.locale || config.DEFAULT_LOCALE;
|
|
253
|
-
if (!existing.meta.controlVersion || existing.meta.controlVersion <
|
|
391
|
+
if (!existing.meta.controlVersion || existing.meta.controlVersion < config.DEFAULT_CONTROL_VERSION) existing.meta.controlVersion = config.DEFAULT_CONTROL_VERSION;
|
|
254
392
|
existing.meta.updatedAt = nowIso();
|
|
255
393
|
fs.writeFileSync(controlFile, `${JSON.stringify(existing, null, 2)}\n`, "utf8");
|
|
256
394
|
console.log(t("init.updated", { file: "project_control.json" }));
|