@brainervirus/workit-core 0.11.0 → 1.0.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 +5 -3
- package/package.json +8 -3
- package/scripts/analyze-release-scope.ts +4 -1
- package/scripts/doctor-check.ts +3 -1
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +1 -0
- package/scripts/install-opencode-plugin.sh +1 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/rewrite-workspace-deps.ts +22 -5
- package/scripts/sync-release-manifests.ts +8 -5
- package/scripts/sync-runtime.sh +7 -3
- package/scripts/validate-cursor-marketplace.ts +16 -62
- package/skills/workit-babysit/SKILL.md +33 -0
- package/skills/workit-behavioral-tdd/SKILL.md +53 -0
- package/skills/workit-blast-radius/SKILL.md +31 -0
- package/skills/workit-challenge/SKILL.md +62 -0
- package/skills/workit-debug/SKILL.md +61 -0
- package/skills/workit-deslop/SKILL.md +36 -0
- package/skills/workit-diagram/SKILL.md +32 -0
- package/skills/workit-green-run/SKILL.md +29 -0
- package/skills/workit-handoff/SKILL.md +43 -0
- package/skills/workit-implement/SKILL.md +46 -0
- package/skills/workit-mockup/SKILL.md +28 -0
- package/skills/workit-plan/SKILL.md +66 -0
- package/skills/workit-review/SKILL.md +60 -0
- package/skills/workit-steer/SKILL.md +32 -0
- package/src/core/authority.ts +918 -0
- package/src/core/boundary.ts +29 -0
- package/src/core/branch.ts +26 -255
- package/src/core/changelog.ts +95 -18
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-conversion.ts +183 -0
- package/src/core/config.ts +42 -0
- package/src/core/cutover.ts +721 -0
- package/src/core/detect-hosts.ts +107 -0
- package/src/core/doctor.ts +428 -18
- package/src/core/external-action-effects.ts +1171 -0
- package/src/core/external-action.ts +541 -0
- package/src/core/init.ts +83 -32
- package/src/core/methods.ts +125 -0
- package/src/core/policy-resolver.ts +523 -0
- package/src/core/pr-create.ts +7 -1
- package/src/core/registration.ts +69 -4
- package/src/core/repo-context.ts +59 -1
- package/src/core/setup.ts +28 -0
- package/src/core/skill-manifests.ts +40 -42
- package/src/core/support-matrix.ts +8 -3
- package/src/core/sync-runtime.ts +15 -3
- package/src/core/task-context.ts +179 -0
- package/src/core/task-contract.ts +1136 -0
- package/src/core/task-engine.ts +2562 -0
- package/src/core/task-evaluation.ts +679 -0
- package/src/core/task-store.ts +1071 -0
- package/src/core/tracker-issues.ts +237 -0
- package/src/core/uninstall.ts +67 -3
- package/src/core/vcs-config.ts +53 -22
- package/src/core/workers.ts +166 -0
- package/src/core/workspaces.ts +3 -1
- package/src/core/youtrack-tools.ts +2 -0
- package/src/core/youtrack.ts +145 -10
- package/src/core.ts +126 -0
- package/templates/execution-contract.md +17 -50
- package/templates/plan-template.md +3 -3
- package/templates/spec-template.md +15 -3
- package/templates/workit-contract.md +12 -0
- package/commands/wk-changelog.md +0 -2
- package/commands/wk-commit.md +0 -2
- package/commands/wk-docs-refresh.md +0 -2
- package/commands/wk-handoff.md +0 -2
- package/commands/wk-implement.md +0 -2
- package/commands/wk-init.md +0 -2
- package/commands/wk-issue-update.md +0 -2
- package/commands/wk-meetings.md +0 -2
- package/commands/wk-pr.md +0 -2
- package/commands/wk-release-notes.md +0 -2
- package/commands/wk-status.md +0 -2
- package/commands/wk-verify.md +0 -2
- package/scripts/update-superpowers.sh +0 -82
- package/scripts/vendor-assets.ts +0 -37
- package/skills/wk-changelog/SKILL.md +0 -15
- package/skills/wk-commit/SKILL.md +0 -16
- package/skills/wk-docs-refresh/SKILL.md +0 -15
- package/skills/wk-handoff/SKILL.md +0 -20
- package/skills/wk-implement/SKILL.md +0 -47
- package/skills/wk-init/SKILL.md +0 -31
- package/skills/wk-issue-update/SKILL.md +0 -27
- package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
- package/skills/wk-meetings/SKILL.md +0 -17
- package/skills/wk-pr/SKILL.md +0 -27
- package/skills/wk-release-notes/SKILL.md +0 -15
- package/skills/wk-status/SKILL.md +0 -16
- package/skills/wk-verify/SKILL.md +0 -16
- package/src/core/detector.ts +0 -239
- package/src/core/flow-state.ts +0 -3308
- package/src/core/handoff-context.ts +0 -136
- package/src/core/handoff-tools.ts +0 -133
- package/src/core/menu.ts +0 -70
- package/src/core/plan-tasks.ts +0 -43
- package/src/core/reminder.ts +0 -124
- package/src/core/sdd.ts +0 -353
- package/src/state.ts +0 -22
- package/templates/superpowers-doc-contract.md +0 -75
- package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
- package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
- package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
- package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
- package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
- package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
- package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
- package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
- package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
- package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
- package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
- package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
- package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
- package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -428
- package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
- package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
- package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
- package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
- package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
- package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
- package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
- package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
- package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
- package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
- package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
- package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
- package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
- package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
- package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
- package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
- package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
- package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
- package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
- package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
- package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
- package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
- package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
- package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
- package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
- package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
- package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
- package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
package/src/core/boundary.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
1
2
|
import type { JsonValue } from "./logger";
|
|
2
3
|
|
|
3
4
|
// Named diagnostic boundary events (DG-04) shared by all adapters so event
|
|
@@ -25,3 +26,31 @@ export const errorDetail = (err: unknown): Record<string, JsonValue> => {
|
|
|
25
26
|
}
|
|
26
27
|
return { error_name: "unknown", error: String(err) };
|
|
27
28
|
};
|
|
29
|
+
|
|
30
|
+
export type SourceMarker = { loadedAt: number; files: string[] };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Long-lived adapters (OpenCode plugin, MCP servers) load core once per
|
|
34
|
+
* process. A marker plus changedSourcesSinceLoad lets them warn once when the
|
|
35
|
+
* checkout sources move underneath the live process, instead of running stale
|
|
36
|
+
* behavior silently. Warning-only: it never blocks a call.
|
|
37
|
+
*/
|
|
38
|
+
export const markSourcesLoaded = (
|
|
39
|
+
files: string[],
|
|
40
|
+
loadedAt: number = Date.now(),
|
|
41
|
+
): SourceMarker => ({
|
|
42
|
+
loadedAt,
|
|
43
|
+
files: [...files],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const changedSourcesSinceLoad = (marker: SourceMarker): string[] => {
|
|
47
|
+
const changed: string[] = [];
|
|
48
|
+
for (const file of marker.files) {
|
|
49
|
+
try {
|
|
50
|
+
if (statSync(file).mtimeMs > marker.loadedAt) changed.push(file);
|
|
51
|
+
} catch {
|
|
52
|
+
// A missing source tells nothing; ignore it.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return changed;
|
|
56
|
+
};
|
package/src/core/branch.ts
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
copyFileSync,
|
|
3
|
-
cpSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
readdirSync,
|
|
8
|
-
renameSync,
|
|
9
|
-
rmSync,
|
|
10
|
-
statSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
2
|
import { execFileSync } from "node:child_process";
|
|
14
|
-
import { createHash } from "node:crypto";
|
|
15
|
-
import { tmpdir } from "node:os";
|
|
16
3
|
import path from "node:path";
|
|
17
4
|
import { gitContext } from "./git";
|
|
18
|
-
import { readConfig, resolveBranchPolicy } from "./config";
|
|
5
|
+
import { readConfig, resolveBranchPolicy, resolveCommitPolicy } from "./config";
|
|
19
6
|
import { resolveWorkspace } from "./workspaces";
|
|
20
7
|
import { vcsConfig } from "./vcs-config";
|
|
21
8
|
|
|
@@ -23,6 +10,10 @@ import { vcsConfig } from "./vcs-config";
|
|
|
23
10
|
export const resolveBranchPolicyFor = (workspaceRoot: string) =>
|
|
24
11
|
resolveBranchPolicy(readConfig(), resolveWorkspace(workspaceRoot));
|
|
25
12
|
|
|
13
|
+
/** Commit-flavor equivalent: workspace commitPolicy override, else global. */
|
|
14
|
+
export const resolveCommitPolicyFor = (workspaceRoot: string) =>
|
|
15
|
+
resolveCommitPolicy(readConfig(), resolveWorkspace(workspaceRoot));
|
|
16
|
+
|
|
26
17
|
const policy = (root: string) => resolveBranchPolicyFor(root);
|
|
27
18
|
const allowedBranch = (root: string, name: string) =>
|
|
28
19
|
policy(root).allowed.some((r) => r.test(name));
|
|
@@ -38,7 +29,6 @@ const USE_CURRENT_RE = /^\s*\*+Branch:\*+\s*use-current\s*$/im;
|
|
|
38
29
|
// Windows portability: journal lines and stash-coverage sets carry
|
|
39
30
|
// repo-relative paths, which must match git's POSIX separator output even
|
|
40
31
|
// though path.join emits platform separators.
|
|
41
|
-
const toPosix = (p: string) => p.split(path.sep).join("/");
|
|
42
32
|
const readSafe = (p: string): string | null => {
|
|
43
33
|
try {
|
|
44
34
|
return readFileSync(p, "utf8");
|
|
@@ -278,114 +268,6 @@ export const ensureBaseBranch = (cwd: string, base: string): { ok: boolean; erro
|
|
|
278
268
|
return fastForwardBase(cwd, base);
|
|
279
269
|
};
|
|
280
270
|
|
|
281
|
-
// CA-05: crash-orphaned guard roots are garbage-collected at snapshot time.
|
|
282
|
-
// Fresh roots (any live invocation) and non-workit entries are never touched.
|
|
283
|
-
export const purgeStaleFlowGuardRoots = (now: number): void => {
|
|
284
|
-
const cutoff = now - 24 * 3600_000;
|
|
285
|
-
let entries: string[];
|
|
286
|
-
try {
|
|
287
|
-
entries = readdirSync(tmpdir());
|
|
288
|
-
} catch {
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
for (const entry of entries) {
|
|
292
|
-
if (!entry.startsWith("workit-flow-guard-")) continue;
|
|
293
|
-
try {
|
|
294
|
-
if (statSync(path.join(tmpdir(), entry)).mtimeMs < cutoff)
|
|
295
|
-
rmSync(path.join(tmpdir(), entry), { recursive: true, force: true });
|
|
296
|
-
} catch {
|
|
297
|
-
/* raced removal or unreadable entry: skip */
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
// CA-04: flow-state snapshots live under the OS tempdir scoped by a hash of
|
|
303
|
-
// the workspace path — never inside the repository or docs/. The pid+hrtime
|
|
304
|
-
// suffix makes every invocation unique, so two concurrent setups never share
|
|
305
|
-
// (and never clobber) a root; CA-05 GC reclaims anything a crash orphaned.
|
|
306
|
-
export const snapshotFlowState = (cwd: string): string => {
|
|
307
|
-
purgeStaleFlowGuardRoots(Date.now());
|
|
308
|
-
const root = path.join(
|
|
309
|
-
tmpdir(),
|
|
310
|
-
`workit-flow-guard-${createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 16)}.${
|
|
311
|
-
process.pid
|
|
312
|
-
}.${process.hrtime.bigint()}`,
|
|
313
|
-
);
|
|
314
|
-
mkdirSync(root, { recursive: true });
|
|
315
|
-
const docsDir = path.join(path.resolve(cwd), "docs");
|
|
316
|
-
let slugs: string[] = [];
|
|
317
|
-
try {
|
|
318
|
-
slugs = readdirSync(docsDir);
|
|
319
|
-
} catch {
|
|
320
|
-
return root; // no docs/ yet — zero-file snapshot
|
|
321
|
-
}
|
|
322
|
-
for (const slug of slugs) {
|
|
323
|
-
// The approval gate digests docs/<slug>/spec.md and docs/<slug>/plan.md,
|
|
324
|
-
// so those bytes must survive the stash window too: a stash push -u takes
|
|
325
|
-
// untracked spec/plan away and any concurrent effective flow read would
|
|
326
|
-
// classify document_missing drift and persist an approval-chain wipe.
|
|
327
|
-
for (const rel of [["sdd", "flow.json"], ["spec.md"], ["plan.md"]]) {
|
|
328
|
-
const src = path.join(docsDir, slug, ...rel);
|
|
329
|
-
try {
|
|
330
|
-
if (!statSync(src).isFile()) continue;
|
|
331
|
-
} catch {
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
const dest = path.join(root, "docs", slug, ...rel);
|
|
335
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
336
|
-
cpSync(src, dest);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
return root;
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
// CA-04: restore-if-missing keeps the newest working-tree bytes; the snapshot
|
|
343
|
-
// root is removed only after every file is handled and retained on failure.
|
|
344
|
-
// A caught failure must not vanish: the message is returned so callers can
|
|
345
|
-
// surface it to operators as a warning. `restored` lists ONLY the files this
|
|
346
|
-
// call actually wrote — present-at-restore files are skipped and excluded,
|
|
347
|
-
// which is what makes the redundant-stash coverage check honest.
|
|
348
|
-
export const restoreFlowSnapshot = (
|
|
349
|
-
snapDir: string,
|
|
350
|
-
cwd: string,
|
|
351
|
-
): { restored: string[]; warning?: string } => {
|
|
352
|
-
const walk = (dir: string, rel: string): string[] =>
|
|
353
|
-
readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
|
|
354
|
-
entry.isDirectory()
|
|
355
|
-
? walk(path.join(dir, entry.name), path.join(rel, entry.name))
|
|
356
|
-
: [path.join(rel, entry.name)],
|
|
357
|
-
);
|
|
358
|
-
const restored: string[] = [];
|
|
359
|
-
try {
|
|
360
|
-
const workspace = path.resolve(cwd);
|
|
361
|
-
for (const rel of walk(snapDir, "")) {
|
|
362
|
-
const dest = path.join(workspace, rel);
|
|
363
|
-
if (existsSync(dest)) continue;
|
|
364
|
-
mkdirSync(path.dirname(dest), { recursive: true });
|
|
365
|
-
// Atomic publish: a crash mid-copy must never leave a truncated flow.json
|
|
366
|
-
// at the destination.
|
|
367
|
-
const tmpDest = `${dest}.tmp-${process.pid}`;
|
|
368
|
-
try {
|
|
369
|
-
copyFileSync(path.join(snapDir, rel), tmpDest);
|
|
370
|
-
renameSync(tmpDest, dest);
|
|
371
|
-
} catch (error) {
|
|
372
|
-
rmSync(tmpDest, { force: true });
|
|
373
|
-
throw error;
|
|
374
|
-
}
|
|
375
|
-
restored.push(toPosix(rel));
|
|
376
|
-
}
|
|
377
|
-
rmSync(snapDir, { recursive: true, force: true });
|
|
378
|
-
return { restored };
|
|
379
|
-
} catch (error) {
|
|
380
|
-
return {
|
|
381
|
-
restored,
|
|
382
|
-
warning: `flow state snapshot restore failed: ${
|
|
383
|
-
error instanceof Error ? error.message : String(error)
|
|
384
|
-
}`,
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
};
|
|
388
|
-
|
|
389
271
|
// Port of scripts/branch/setup-branch.sh
|
|
390
272
|
export const branchSetup = ({
|
|
391
273
|
action,
|
|
@@ -411,7 +293,11 @@ export const branchSetup = ({
|
|
|
411
293
|
const manifestPath = path.isAbsolute(sdd)
|
|
412
294
|
? path.join(sdd, "manifest.json")
|
|
413
295
|
: path.join(cwd, sdd, "manifest.json");
|
|
414
|
-
|
|
296
|
+
const ensureManifestDir = () => {
|
|
297
|
+
const dir = path.dirname(manifestPath);
|
|
298
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
299
|
+
};
|
|
300
|
+
ensureManifestDir();
|
|
415
301
|
const readManifest = (): Record<string, unknown> => {
|
|
416
302
|
try {
|
|
417
303
|
return JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
@@ -419,95 +305,29 @@ export const branchSetup = ({
|
|
|
419
305
|
return {};
|
|
420
306
|
}
|
|
421
307
|
};
|
|
422
|
-
const writeManifest = (data: Record<string, unknown>) =>
|
|
308
|
+
const writeManifest = (data: Record<string, unknown>) => {
|
|
309
|
+
ensureManifestDir();
|
|
423
310
|
writeFileSync(manifestPath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
424
|
-
|
|
425
|
-
let snapDir: string | null = null;
|
|
426
|
-
// CA-01: flow-guard journal brackets the stash/checkout mutation window so a
|
|
427
|
-
// mid-window wipe is pinpointable between adjacent checkpoint lines. With no
|
|
428
|
-
// logger injected every call below is a no-op and nothing extra runs —
|
|
429
|
-
// behaviorally identical to the pre-journal code.
|
|
430
|
-
const journal = (message: string) => log?.(`flow-guard: ${message}`);
|
|
431
|
-
const snapshotRelPaths = (): string[] => {
|
|
432
|
-
const dir = snapDir;
|
|
433
|
-
// Not gated on log: the redundant-stash coverage check below needs this
|
|
434
|
-
// walk even without a logger (the walk is read-only over the tmpdir
|
|
435
|
-
// snapshot).
|
|
436
|
-
if (!dir) return [];
|
|
437
|
-
try {
|
|
438
|
-
const walk = (from: string, rel: string): string[] =>
|
|
439
|
-
readdirSync(from, { withFileTypes: true }).flatMap((entry) =>
|
|
440
|
-
entry.isDirectory()
|
|
441
|
-
? walk(path.join(from, entry.name), path.join(rel, entry.name))
|
|
442
|
-
: [path.join(rel, entry.name)],
|
|
443
|
-
);
|
|
444
|
-
return walk(dir, "").map(toPosix);
|
|
445
|
-
} catch {
|
|
446
|
-
return [];
|
|
447
|
-
}
|
|
448
|
-
};
|
|
449
|
-
const journalSnapshot = () => {
|
|
450
|
-
const dir = snapDir;
|
|
451
|
-
if (!dir || !log) return;
|
|
452
|
-
const rels = snapshotRelPaths();
|
|
453
|
-
journal(`snapshot: ${rels.length} file(s)`);
|
|
454
|
-
for (const rel of rels) {
|
|
455
|
-
let shortHash = "";
|
|
456
|
-
try {
|
|
457
|
-
shortHash = createHash("sha256")
|
|
458
|
-
.update(readFileSync(path.join(dir, rel)))
|
|
459
|
-
.digest("hex")
|
|
460
|
-
.slice(0, 8);
|
|
461
|
-
} catch {}
|
|
462
|
-
journal(`snapshot: ${rel} sha=${shortHash}`);
|
|
463
|
-
}
|
|
464
|
-
};
|
|
465
|
-
const journalPresence = (phase: string) => {
|
|
466
|
-
for (const rel of snapshotRelPaths()) {
|
|
467
|
-
journal(`${phase}: ${rel} ${existsSync(path.join(cwd, rel)) ? "present" : "MISSING"}`);
|
|
468
|
-
}
|
|
469
|
-
};
|
|
470
|
-
const restoreWithWarning = (dir: string | null): { restored: string[]; warnings: string[] } => {
|
|
471
|
-
if (!dir) return { restored: [], warnings: [] };
|
|
472
|
-
const total = log ? snapshotRelPaths().length : 0;
|
|
473
|
-
const { restored, warning } = restoreFlowSnapshot(dir, cwd);
|
|
474
|
-
if (log)
|
|
475
|
-
journal(
|
|
476
|
-
`restore: restored=${restored.length} skipped=${total - restored.length}${warning ? ` warning: ${warning}` : ""}`,
|
|
477
|
-
);
|
|
478
|
-
else if (warning) journal(`restore warning: ${warning}`);
|
|
479
|
-
return { restored, warnings: warning ? [warning] : [] };
|
|
480
311
|
};
|
|
481
312
|
|
|
313
|
+
const journal = (message: string) => log?.(`branch-setup: ${message}`);
|
|
314
|
+
|
|
482
315
|
if (action === "reapply_stash") {
|
|
483
316
|
const manifest = readManifest();
|
|
484
317
|
const ref = manifest.stash_ref;
|
|
485
318
|
if (!ref) return { error: "no stash_ref in manifest" };
|
|
486
|
-
// D-03: guard flow.json across the stash pop window.
|
|
487
|
-
snapDir = snapshotFlowState(cwd);
|
|
488
|
-
journalSnapshot();
|
|
489
319
|
journal(`pre-pop: ${String(ref)}`);
|
|
490
320
|
try {
|
|
491
321
|
exec(["stash", "pop", String(ref)]);
|
|
492
322
|
} catch (error) {
|
|
493
|
-
// CA-03: the snapshot ran before the pop — a failing pop must still
|
|
494
|
-
// restore a mid-window-wiped flow.json before returning.
|
|
495
323
|
journal("pop: failed");
|
|
496
|
-
|
|
497
|
-
warnings: [warning],
|
|
498
|
-
} = restoreWithWarning(snapDir);
|
|
499
|
-
return {
|
|
500
|
-
error: `${error instanceof Error ? error.message : "stash pop failed"}${
|
|
501
|
-
warning ? `; ${warning}` : ""
|
|
502
|
-
}`,
|
|
503
|
-
};
|
|
324
|
+
return { error: error instanceof Error ? error.message : "stash pop failed" };
|
|
504
325
|
}
|
|
505
326
|
journal("pop: ok");
|
|
506
327
|
delete manifest.stash_ref;
|
|
507
328
|
delete manifest.stash_created_at;
|
|
508
329
|
writeManifest(manifest);
|
|
509
|
-
|
|
510
|
-
return { action: "reapply_stash", ok: true, ...(warnings.length > 0 ? { warnings } : {}) };
|
|
330
|
+
return { action: "reapply_stash", ok: true };
|
|
511
331
|
}
|
|
512
332
|
|
|
513
333
|
const target = target_branch ?? "";
|
|
@@ -549,14 +369,7 @@ export const branchSetup = ({
|
|
|
549
369
|
suffix = " (changes preserved in stash)";
|
|
550
370
|
}
|
|
551
371
|
}
|
|
552
|
-
|
|
553
|
-
// here must still restore a mid-window-wiped flow.json and drop the
|
|
554
|
-
// guard root (a retained root is purged by the next run's 24h GC).
|
|
555
|
-
// Never masks the original error.
|
|
556
|
-
const {
|
|
557
|
-
warnings: [warning],
|
|
558
|
-
} = restoreWithWarning(snapDir);
|
|
559
|
-
return { error: `${message}${suffix}${warning ? `; ${warning}` : ""}` };
|
|
372
|
+
return { error: `${message}${suffix}` };
|
|
560
373
|
};
|
|
561
374
|
if (current !== target) {
|
|
562
375
|
const dirty = Boolean(gitContext(cwd).status_short.trim());
|
|
@@ -578,11 +391,7 @@ export const branchSetup = ({
|
|
|
578
391
|
}
|
|
579
392
|
if (dirty) {
|
|
580
393
|
try {
|
|
581
|
-
|
|
582
|
-
// stash/checkout window even if the pathspec exclusion misses.
|
|
583
|
-
snapDir = snapshotFlowState(cwd);
|
|
584
|
-
journalSnapshot();
|
|
585
|
-
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`, "--", ":!docs/*/sdd"]);
|
|
394
|
+
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
|
|
586
395
|
} catch (error) {
|
|
587
396
|
return { error: error instanceof Error ? error.message : "stash push failed" };
|
|
588
397
|
}
|
|
@@ -591,7 +400,7 @@ export const branchSetup = ({
|
|
|
591
400
|
}
|
|
592
401
|
try {
|
|
593
402
|
exec(["checkout", target]);
|
|
594
|
-
|
|
403
|
+
journal("post-checkout");
|
|
595
404
|
} catch (error) {
|
|
596
405
|
const message = error instanceof Error ? error.message : "checkout failed";
|
|
597
406
|
if (/worktree/i.test(message)) {
|
|
@@ -614,7 +423,7 @@ export const branchSetup = ({
|
|
|
614
423
|
: ensureBaseBranch(cwd, effectiveBase);
|
|
615
424
|
if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
|
|
616
425
|
exec(["checkout", "-b", target]);
|
|
617
|
-
|
|
426
|
+
journal("post-create");
|
|
618
427
|
} catch (createError) {
|
|
619
428
|
return failAfterStash(
|
|
620
429
|
createError instanceof Error ? createError.message : "branch create failed",
|
|
@@ -644,50 +453,13 @@ export const branchSetup = ({
|
|
|
644
453
|
if (stash_ref === undefined && gitContext(cwd).branch !== current) {
|
|
645
454
|
try {
|
|
646
455
|
exec(["checkout", current]);
|
|
647
|
-
} catch {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
// Files the guard actually wrote back, straight from the restore itself.
|
|
652
|
-
const { restored: restoredRels, warnings } = restoreWithWarning(snapDir);
|
|
653
|
-
// Round-1 fallout: the guard restores untracked spec/plan right after
|
|
654
|
-
// checkout, but the stash pushed the same files — a later reapply_stash pop
|
|
655
|
-
// refuses ("untracked working tree files would be overwritten"), stranding
|
|
656
|
-
// a stash_ref on every successful setup. When every stashed path was just
|
|
657
|
-
// restored byte-identical from the pre-stash snapshot, the stash is
|
|
658
|
-
// redundant: drop it and clear the manifest ref. Coverage counts ONLY
|
|
659
|
-
// actually-restored paths: a tracked-and-modified file survives its stash
|
|
660
|
-
// (reverted to HEAD bytes, still present), so restore skips it — counting
|
|
661
|
-
// it as covered would drop the only copy of the user's edit. Any stashed
|
|
662
|
-
// path not restored keeps the ref so reapply_stash stays available.
|
|
663
|
-
// Best-effort: any failure keeps today's keep-the-ref behavior.
|
|
664
|
-
if (stash_ref && snapDir && warnings.length === 0) {
|
|
665
|
-
const covered = new Set(restoredRels.map(toPosix));
|
|
666
|
-
try {
|
|
667
|
-
const stashed = exec([
|
|
668
|
-
"stash",
|
|
669
|
-
"show",
|
|
670
|
-
"--include-untracked",
|
|
671
|
-
"--name-only",
|
|
672
|
-
String(stash_ref),
|
|
673
|
-
])
|
|
674
|
-
.split("\n")
|
|
675
|
-
.map((line) => toPosix(line.trim()))
|
|
676
|
-
.filter(Boolean);
|
|
677
|
-
if (stashed.length > 0 && stashed.every((p) => covered.has(p))) {
|
|
678
|
-
exec(["stash", "drop", String(stash_ref)]);
|
|
679
|
-
stash_ref = undefined;
|
|
680
|
-
journal("dropped redundant stash (fully covered by snapshot restore)");
|
|
681
|
-
const manifest = readManifest();
|
|
682
|
-
delete manifest.stash_ref;
|
|
683
|
-
delete manifest.stash_created_at;
|
|
684
|
-
writeManifest(manifest);
|
|
685
|
-
} else {
|
|
686
|
-
journal(`kept stash ref ${stash_ref} (not fully covered by actual restores)`);
|
|
456
|
+
} catch {
|
|
457
|
+
try {
|
|
458
|
+
exec(["checkout", "-m", current]);
|
|
459
|
+
} catch {}
|
|
687
460
|
}
|
|
688
|
-
} catch {
|
|
689
|
-
/* keep the stash ref — reapply_stash stays available */
|
|
690
461
|
}
|
|
462
|
+
return result;
|
|
691
463
|
}
|
|
692
464
|
return {
|
|
693
465
|
action: "setup",
|
|
@@ -696,6 +468,5 @@ export const branchSetup = ({
|
|
|
696
468
|
previous_branch: current,
|
|
697
469
|
stash_ref: stash_ref ?? null,
|
|
698
470
|
manifest: manifestPath,
|
|
699
|
-
...(warnings.length > 0 ? { warnings } : {}),
|
|
700
471
|
};
|
|
701
472
|
};
|
package/src/core/changelog.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { resolveWorkspaceRoot } from "./scripts";
|
|
4
5
|
|
|
5
6
|
const CATEGORIES = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"];
|
|
@@ -23,6 +24,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
23
24
|
|
|
24
25
|
`;
|
|
25
26
|
|
|
27
|
+
const digestBytes = (value: Buffer | string): string =>
|
|
28
|
+
createHash("sha256").update(value).digest("hex");
|
|
29
|
+
|
|
30
|
+
type ChangelogSource = { exists: boolean; bytes: Buffer; text: string } | { error: string };
|
|
31
|
+
|
|
32
|
+
const readChangelogSource = (target: string): ChangelogSource => {
|
|
33
|
+
if (!fs.existsSync(target)) {
|
|
34
|
+
const bytes = Buffer.from(SKELETON, "utf8");
|
|
35
|
+
return { exists: false, bytes, text: SKELETON };
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const bytes = fs.readFileSync(target);
|
|
39
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
40
|
+
return { exists: true, bytes, text };
|
|
41
|
+
} catch {
|
|
42
|
+
return { error: "changelog file must contain valid UTF-8" };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
26
46
|
function normalizeBullet(text: string): string {
|
|
27
47
|
text = text.trim();
|
|
28
48
|
const m = BULLET_RE.exec(text);
|
|
@@ -184,13 +204,6 @@ function mergeSections(
|
|
|
184
204
|
return [output, counts, added, null];
|
|
185
205
|
}
|
|
186
206
|
|
|
187
|
-
function ensureFile(target: string): string {
|
|
188
|
-
if (fs.existsSync(target)) return fs.readFileSync(target, "utf8");
|
|
189
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
190
|
-
fs.writeFileSync(target, SKELETON, "utf8");
|
|
191
|
-
return SKELETON;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
207
|
function hasUnreleasedHeading(text: string): boolean {
|
|
195
208
|
return text.split("\n").some((line) => UNRELEASED_RE.test(line.replace(/\n$/, "")));
|
|
196
209
|
}
|
|
@@ -200,7 +213,21 @@ function applyChangelog(
|
|
|
200
213
|
entries: Record<string, string[]>,
|
|
201
214
|
normalizeOnly: boolean,
|
|
202
215
|
): Record<string, any> {
|
|
203
|
-
|
|
216
|
+
const source = readChangelogSource(target);
|
|
217
|
+
if ("error" in source) return { error: source.error };
|
|
218
|
+
const rendered = renderChangelog(source.text, entries, normalizeOnly);
|
|
219
|
+
if ("error" in rendered) return { error: rendered.error };
|
|
220
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
221
|
+
fs.writeFileSync(target, rendered.text, "utf8");
|
|
222
|
+
return { ...rendered.result, path: target };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderChangelog(
|
|
226
|
+
source: string,
|
|
227
|
+
entries: Record<string, string[]>,
|
|
228
|
+
normalizeOnly: boolean,
|
|
229
|
+
): { text: string; result: Record<string, any> } | { error: string } {
|
|
230
|
+
let text = source;
|
|
204
231
|
if (!hasUnreleasedHeading(text)) {
|
|
205
232
|
const lines = text.split(/(?<=\n)/);
|
|
206
233
|
let insertAt = lines.length;
|
|
@@ -220,13 +247,65 @@ function applyChangelog(
|
|
|
220
247
|
if (error) return { error };
|
|
221
248
|
// before already includes "## [Unreleased]\n"
|
|
222
249
|
const out = (before.endsWith("\n") ? before : before + "\n") + newBody + after;
|
|
223
|
-
|
|
250
|
+
return {
|
|
251
|
+
text: out,
|
|
252
|
+
result: { ok: true, normalize_only: normalizeOnly, added: addedCounts, categories: counts },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const changelogTarget = (
|
|
257
|
+
workspaceRoot: string,
|
|
258
|
+
changelogPath: string,
|
|
259
|
+
): { cwd: string; target: string } | { error: string } => {
|
|
260
|
+
try {
|
|
261
|
+
const cwd = fs.realpathSync(resolveWorkspaceRoot(workspaceRoot));
|
|
262
|
+
const target = path.resolve(cwd, changelogPath || "CHANGELOG.md");
|
|
263
|
+
if (target !== cwd && !target.startsWith(cwd + path.sep))
|
|
264
|
+
return { error: "changelog path must be inside workspace_root" };
|
|
265
|
+
let ancestor = target;
|
|
266
|
+
while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor);
|
|
267
|
+
const canonicalAncestor = fs.realpathSync(ancestor);
|
|
268
|
+
if (canonicalAncestor !== cwd && !canonicalAncestor.startsWith(cwd + path.sep))
|
|
269
|
+
return { error: "changelog path must be inside workspace_root" };
|
|
270
|
+
return { cwd, target: fs.existsSync(target) ? fs.realpathSync(target) : target };
|
|
271
|
+
} catch {
|
|
272
|
+
return { error: "changelog path must be inside workspace_root" };
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export function changelogApplyPreview({
|
|
277
|
+
entries,
|
|
278
|
+
path: changelogPath,
|
|
279
|
+
normalize_only,
|
|
280
|
+
workspace_root,
|
|
281
|
+
}: {
|
|
282
|
+
entries?: unknown;
|
|
283
|
+
path?: string;
|
|
284
|
+
normalize_only?: boolean;
|
|
285
|
+
workspace_root: string;
|
|
286
|
+
}): Record<string, any> {
|
|
287
|
+
const normalized = normalize_only
|
|
288
|
+
? { data: {} as Record<string, string[]> }
|
|
289
|
+
: normalizeEntries(entries);
|
|
290
|
+
if ("error" in normalized) return { error: normalized.error };
|
|
291
|
+
if (!normalize_only && Object.keys(normalized.data).length === 0)
|
|
292
|
+
return { error: "entries required unless normalize_only" };
|
|
293
|
+
const location = changelogTarget(workspace_root, changelogPath || "CHANGELOG.md");
|
|
294
|
+
if ("error" in location) return location;
|
|
295
|
+
const source = readChangelogSource(location.target);
|
|
296
|
+
if ("error" in source) return source;
|
|
297
|
+
const rendered = renderChangelog(source.text, normalized.data, Boolean(normalize_only));
|
|
298
|
+
if ("error" in rendered) return rendered;
|
|
224
299
|
return {
|
|
225
300
|
ok: true,
|
|
226
|
-
path: target,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
301
|
+
path: location.target,
|
|
302
|
+
entries: normalized.data,
|
|
303
|
+
normalize_only: Boolean(normalize_only),
|
|
304
|
+
beforeDigest: digestBytes(source.bytes),
|
|
305
|
+
beforeExists: source.exists,
|
|
306
|
+
afterDigest: digestBytes(Buffer.from(rendered.text, "utf8")),
|
|
307
|
+
after: rendered.text,
|
|
308
|
+
...rendered.result,
|
|
230
309
|
};
|
|
231
310
|
}
|
|
232
311
|
|
|
@@ -280,11 +359,9 @@ export function changelogApply({
|
|
|
280
359
|
}
|
|
281
360
|
|
|
282
361
|
const rel = changelogPath || "CHANGELOG.md";
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return { error: "changelog path must be inside workspace_root" };
|
|
287
|
-
}
|
|
362
|
+
const location = changelogTarget(cwd, rel);
|
|
363
|
+
if ("error" in location) return location;
|
|
364
|
+
const target = location.target;
|
|
288
365
|
|
|
289
366
|
try {
|
|
290
367
|
return applyChangelog(target, normalized.data, Boolean(normalize_only));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Commit message flavors: matchers plus history detection for the git.commit
|
|
2
|
+
// gate. Detection is deterministic (majority vote, no LLM): sample recent
|
|
3
|
+
// subjects, classify each, take the majority above threshold, else null so the
|
|
4
|
+
// caller falls back to the configured preset.
|
|
5
|
+
|
|
6
|
+
export type CommitFlavor = "conventional" | "gitmoji" | "ticket-prefix" | "freeform";
|
|
7
|
+
export type CommitFlavorPreset = CommitFlavor | "custom" | "auto";
|
|
8
|
+
|
|
9
|
+
const CONVENTIONAL_RE =
|
|
10
|
+
/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?(!)?: .+/;
|
|
11
|
+
const TICKET_RE = /^[A-Z]{2,}-\d+\b/;
|
|
12
|
+
const GITMOJI_RE = /^\p{Extended_Pictographic}/u;
|
|
13
|
+
|
|
14
|
+
export const matchCommitFlavor = (
|
|
15
|
+
message: string,
|
|
16
|
+
flavor: CommitFlavorPreset,
|
|
17
|
+
pattern?: string,
|
|
18
|
+
): boolean => {
|
|
19
|
+
const subject = message.split("\n", 1)[0] ?? "";
|
|
20
|
+
switch (flavor) {
|
|
21
|
+
case "conventional":
|
|
22
|
+
return CONVENTIONAL_RE.test(subject);
|
|
23
|
+
case "gitmoji":
|
|
24
|
+
return GITMOJI_RE.test(subject);
|
|
25
|
+
case "ticket-prefix":
|
|
26
|
+
return TICKET_RE.test(subject);
|
|
27
|
+
case "freeform":
|
|
28
|
+
return subject.trim().length > 0;
|
|
29
|
+
case "custom":
|
|
30
|
+
if (!pattern) return false;
|
|
31
|
+
try {
|
|
32
|
+
return new RegExp(pattern).test(subject);
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
case "auto":
|
|
37
|
+
return subject.trim().length > 0;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DETECTABLE: CommitFlavor[] = ["conventional", "gitmoji", "ticket-prefix"];
|
|
42
|
+
|
|
43
|
+
export const detectCommitFlavor = (
|
|
44
|
+
subjects: string[],
|
|
45
|
+
threshold = 0.7,
|
|
46
|
+
): { flavor: CommitFlavor | null; confidence: number } => {
|
|
47
|
+
if (subjects.length === 0) return { flavor: null, confidence: 0 };
|
|
48
|
+
const votes = new Map<CommitFlavor, number>();
|
|
49
|
+
for (const subject of subjects) {
|
|
50
|
+
const hit = DETECTABLE.find((flavor) => matchCommitFlavor(subject, flavor));
|
|
51
|
+
if (hit) votes.set(hit, (votes.get(hit) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
let best: CommitFlavor | null = null;
|
|
54
|
+
let count = 0;
|
|
55
|
+
for (const [flavor, votesFor] of votes) {
|
|
56
|
+
if (votesFor > count) {
|
|
57
|
+
best = flavor;
|
|
58
|
+
count = votesFor;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const confidence = count / subjects.length;
|
|
62
|
+
return best !== null && confidence >= threshold
|
|
63
|
+
? { flavor: best, confidence }
|
|
64
|
+
: { flavor: null, confidence };
|
|
65
|
+
};
|