peaks-loop 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README-en.md +26 -0
  3. package/README.md +26 -0
  4. package/dist/cli/commands/_register.js +3 -0
  5. package/dist/cli/commands/code-runtime-commands.js +15 -2
  6. package/dist/cli/commands/core/skill-command.js +2 -0
  7. package/dist/cli/commands/dispatch-commands.js +41 -2
  8. package/dist/cli/commands/e2e-verify.d.ts +47 -0
  9. package/dist/cli/commands/e2e-verify.js +211 -0
  10. package/dist/cli/commands/gate-commands.js +17 -4
  11. package/dist/cli/commands/statusline-commands.d.ts +6 -0
  12. package/dist/cli/commands/statusline-commands.js +48 -11
  13. package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
  14. package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
  15. package/dist/cli/commands/worktree-auth-commands.js +27 -0
  16. package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
  17. package/dist/services/code/auto-compact-orchestrator.js +358 -41
  18. package/dist/services/code/post-compact-detector.js +12 -4
  19. package/dist/services/code/step-08-gate.js +6 -2
  20. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
  21. package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
  22. package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
  23. package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
  24. package/dist/services/context/auto-compact-reader.d.ts +33 -4
  25. package/dist/services/context/auto-compact-reader.js +77 -22
  26. package/dist/services/context/auto-compact-types.d.ts +10 -2
  27. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  28. package/dist/services/context/build-dispatch-system-prompt.js +33 -2
  29. package/dist/services/dispatch/conflict-replay.d.ts +32 -0
  30. package/dist/services/dispatch/conflict-replay.js +44 -0
  31. package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
  32. package/dist/services/dispatch/dispatch-record-writer.js +27 -7
  33. package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
  34. package/dist/services/dispatch/e2e-fixtures.js +72 -0
  35. package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
  36. package/dist/services/dispatch/merge-back-runner.js +83 -0
  37. package/dist/services/dispatch/post-merge.d.ts +21 -0
  38. package/dist/services/dispatch/post-merge.js +13 -0
  39. package/dist/services/dispatch/service-shutdown.d.ts +22 -0
  40. package/dist/services/dispatch/service-shutdown.js +51 -0
  41. package/dist/services/hooks/presence-marker-detector.js +12 -4
  42. package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
  43. package/dist/services/hooks/worktree-authorization-gate.js +22 -0
  44. package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
  45. package/dist/services/skills/skill-statusline-renderer.js +374 -29
  46. package/dist/services/skills/skill-statusline-service.d.ts +2 -0
  47. package/dist/services/skills/skill-statusline-service.js +34 -5
  48. package/dist/services/skills/sync-service.d.ts +3 -0
  49. package/dist/services/skills/sync-service.js +1 -0
  50. package/dist/services/standards/project-context.d.ts +2 -1
  51. package/dist/services/standards/project-context.js +17 -2
  52. package/dist/services/standards/project-standards-service.d.ts +9 -0
  53. package/dist/services/standards/project-standards-service.js +26 -0
  54. package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
  55. package/dist/services/worktree/dispatch-provenance.js +47 -0
  56. package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
  57. package/dist/services/worktree/git-worktree-parser.js +45 -0
  58. package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
  59. package/dist/services/worktree/host-worktree-reconciler.js +39 -0
  60. package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
  61. package/dist/services/worktree/long-path-cleanup.js +38 -0
  62. package/dist/services/worktree/playwright-profile.d.ts +8 -0
  63. package/dist/services/worktree/playwright-profile.js +23 -0
  64. package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
  65. package/dist/services/worktree/worktree-gc-guard.js +38 -0
  66. package/package.json +4 -4
  67. package/scripts/install-skills.mjs +11 -2
@@ -0,0 +1,13 @@
1
+ export function planMergeBack(input) {
2
+ if (input.agentBranch.length === 0)
3
+ return { kind: 'missing', reason: 'agent-branch-empty' };
4
+ if (input.callerBranch === input.agentBranch)
5
+ return { kind: 'noop' };
6
+ if (input.conflictingFiles.length > 0)
7
+ return { kind: 'conflict', conflictingFiles: input.conflictingFiles };
8
+ const base = ['git', 'merge', '--no-ff'];
9
+ if (input.callerBranch === 'main' && input.commitsBehind === 0) {
10
+ return { kind: 'fast-forward', command: ['git', 'merge', '--ff-only', input.agentBranch] };
11
+ }
12
+ return { kind: 'no-ff', command: [...base, input.agentBranch] };
13
+ }
@@ -0,0 +1,22 @@
1
+ export type ServiceRegistration = {
2
+ readonly pid: number;
3
+ readonly name: string;
4
+ readonly url?: string;
5
+ };
6
+ export type ServiceKillOutcome = {
7
+ readonly pid: number;
8
+ readonly name: string;
9
+ readonly skipped: false;
10
+ readonly signal: 'SIGTERM' | 'SIGKILL' | 'taskkill';
11
+ };
12
+ export type ServiceKillSkipped = {
13
+ readonly pid: number;
14
+ readonly name: string;
15
+ readonly skipped: true;
16
+ readonly reason: 'not-running';
17
+ };
18
+ export type ServiceKillResult = ServiceKillOutcome | ServiceKillSkipped;
19
+ export declare function killRegisteredServices(input: {
20
+ readonly registrations: ReadonlyArray<ServiceRegistration>;
21
+ readonly platform?: NodeJS.Platform;
22
+ }): ReadonlyArray<ServiceKillResult>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * service-shutdown — best-effort kill helper for sub-agent-registered
3
+ * local services.
4
+ *
5
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 3). The parent session
6
+ * reads `.peaks/_runtime/<sid>/dispatch/<dispatchId>/service-registrations.json`
7
+ * (written by `peaks sub-agent shutdown register --pid <pid> --name <label>`)
8
+ * and calls `killRegisteredServices` BEFORE the merge-back step so a
9
+ * long-lived `vite dev` / mock-API / docker-proxy process started by the
10
+ * sub-agent does not block the merge (file lock, port collision, dirty
11
+ * db, etc).
12
+ *
13
+ * The helper is intentionally minimal:
14
+ * - pid <= 0 or pid === process.pid → skip (`not-running`).
15
+ * - win32: `taskkill /T /F /PID <pid>` (force, with subtree).
16
+ * - POSIX: `kill -TERM <pid>`; on failure escalate to `kill -KILL`.
17
+ * - any error → `not-running` (best-effort; the parent will retry
18
+ * merge regardless).
19
+ *
20
+ * All native calls are best-effort and never throw; the orchestrator
21
+ * (merge-back-runner.ts) just records the array of outcomes on the
22
+ * dispatch record's `serviceKill` field (added in Task 7).
23
+ */
24
+ import { execFileSync } from 'node:child_process';
25
+ export function killRegisteredServices(input) {
26
+ const platform = input.platform ?? process.platform;
27
+ return input.registrations.map((reg) => {
28
+ if (reg.pid <= 0 || reg.pid === process.pid) {
29
+ return { pid: reg.pid, name: reg.name, skipped: true, reason: 'not-running' };
30
+ }
31
+ try {
32
+ if (platform === 'win32') {
33
+ execFileSync('taskkill', ['/T', '/F', '/PID', String(reg.pid)], { stdio: 'ignore' });
34
+ return { pid: reg.pid, name: reg.name, skipped: false, signal: 'taskkill' };
35
+ }
36
+ // POSIX: try SIGTERM via the `kill` CLI (universally available);
37
+ // escalate to SIGKILL on failure. The runner is best-effort.
38
+ try {
39
+ execFileSync('kill', ['-TERM', String(reg.pid)], { stdio: 'ignore' });
40
+ }
41
+ catch {
42
+ execFileSync('kill', ['-KILL', String(reg.pid)], { stdio: 'ignore' });
43
+ return { pid: reg.pid, name: reg.name, skipped: false, signal: 'SIGKILL' };
44
+ }
45
+ return { pid: reg.pid, name: reg.name, skipped: false, signal: 'SIGTERM' };
46
+ }
47
+ catch {
48
+ return { pid: reg.pid, name: reg.name, skipped: true, reason: 'not-running' };
49
+ }
50
+ });
51
+ }
@@ -46,15 +46,23 @@ function readPresenceFile(absolutePath) {
46
46
  try {
47
47
  raw = readFileSync(absolutePath, 'utf8');
48
48
  }
49
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
50
- return null;
49
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
50
+ if (err instanceof ReferenceError)
51
+ throw err; // surface module-load bugs
52
+ if (err instanceof SyntaxError)
53
+ throw err; // surface parse bugs
54
+ return null; // only swallow IO errors
51
55
  }
52
56
  let parsed;
53
57
  try {
54
58
  parsed = JSON.parse(raw);
55
59
  }
56
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
57
- return null;
60
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
61
+ if (err instanceof ReferenceError)
62
+ throw err; // surface module-load bugs
63
+ if (err instanceof SyntaxError)
64
+ throw err; // surface parse bugs
65
+ return null; // only swallow IO errors
58
66
  }
59
67
  if (parsed === null || typeof parsed !== 'object')
60
68
  return null;
@@ -104,6 +104,8 @@ export type WorktreeAuthDenyCode =
104
104
  /** Slice 2026-07-29-worktree-l2-extended Part 19: container
105
105
  * lease id is set but the lease file is unreadable / malformed. */
106
106
  | 'CONTAINER_LEASE_FILE_INVALID'
107
+ /** Host Agent/Task worktree isolation was not issued by Peaks dispatch. */
108
+ | 'HOST_AGENT_ISOLATION_UNMANAGED'
107
109
  /** Container lease exists but is not active (status != 'active' OR past expiresAt). */
108
110
  | 'CONTAINER_LEASE_NOT_ACTIVE'
109
111
  /** Container lease exists but its rid does not match the current peaks request. */
@@ -139,6 +141,8 @@ export type WorktreeAuthCheckInput = {
139
141
  * is active and its rid matches.
140
142
  */
141
143
  readonly containerLeaseId: string | null;
144
+ /** Peaks-issued provenance token required for host Agent/Task worktree isolation. */
145
+ readonly dispatchProvenanceToken?: string | null;
142
146
  };
143
147
  /** Stable identifier of the current "user authorization" — derived from session + tool + key args. */
144
148
  export declare function currentAuthFingerprint(input: WorktreeAuthCheckInput): string;
@@ -36,6 +36,7 @@ import { createHash } from 'node:crypto';
36
36
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
37
37
  import { dirname, join } from 'node:path';
38
38
  import { deserializeLease, isLeaseActive, leaseFilePath, } from '../worktree/worktree-lease.js';
39
+ import { readDispatchProvenance } from '../worktree/dispatch-provenance.js';
39
40
  import { containerLeaseFilePath, deserializeContainerLease, isContainerLeaseActive, } from '../container/container-lease.js';
40
41
  export const WORKTREE_AUTH_FILE = 'worktree-auth.json';
41
42
  /** Stable identifier of the current "user authorization" — derived from session + tool + key args. */
@@ -232,6 +233,27 @@ export function decideFromAuthorization(input, operation, file) {
232
233
  */
233
234
  export function evaluateWorktreeAuth(input) {
234
235
  const operation = classifyToolCall(input);
236
+ if (operation === 'agent-isolation-worktree') {
237
+ const token = input.dispatchProvenanceToken ?? '';
238
+ const provenance = token.length > 0
239
+ ? readDispatchProvenance({ projectRoot: input.projectRoot, sessionId: input.sessionId, token })
240
+ : null;
241
+ if (provenance === null ||
242
+ input.leaseId === null ||
243
+ provenance.leaseId !== input.leaseId ||
244
+ (input.requestId !== null && provenance.requestId !== input.requestId)) {
245
+ return {
246
+ allow: false,
247
+ code: 'HOST_AGENT_ISOLATION_UNMANAGED',
248
+ reason: 'Host Agent/Task worktree isolation lacks a matching Peaks dispatch provenance record and canonical lease.',
249
+ remediation: 'Route the work through Peaks sub-agent dispatch with worktree isolation. Existing leaked host worktrees must be reconciled or adopted before cleanup.'
250
+ };
251
+ }
252
+ const leaseDecision = decideFromLease({ ...input, requestId: provenance.requestId });
253
+ if (!leaseDecision.allow)
254
+ return leaseDecision;
255
+ return leaseDecision;
256
+ }
235
257
  if (operation === null) {
236
258
  // Pass-through (Read, Glob, etc., and any Bash that isn't worktree-mutating).
237
259
  return {
@@ -1,6 +1,89 @@
1
1
  import type { StatusLineModel } from './skill-statusline-service.js';
2
2
  /**
3
- * Render the status line. The output is plain text with simple status glyphs so
4
- * it stays readable in any terminal; Claude Code applies its own styling.
3
+ * Pure formatting layer for the Peaks statusLine. Takes the read-only status
4
+ * model and produces the single line Claude Code paints at the bottom of the
5
+ * terminal. Kept separate from the reader so formatting can be tested without
6
+ * touching the filesystem.
7
+ *
8
+ * Capability-aware rendering: three glyph palettes are kept side by side in
9
+ * {@link PALETTES} so the switch from "active" / "idle" / "warning" to a
10
+ * concrete string happens in one place. Adding a fourth capability (e.g. for
11
+ * a future IDE) is a one-table change.
5
12
  */
6
- export declare function renderStatusLine(model: StatusLineModel): string;
13
+ export type StatusLineCapability = 'ansi-unicode' | 'unicode' | 'ascii';
14
+ export interface StatusLineRenderOptions {
15
+ readonly capability: StatusLineCapability;
16
+ }
17
+ /**
18
+ * Resolve the capability tier from environment and TTY state. Pure and
19
+ * deterministic — no I/O beyond reading the caller-supplied `env` and
20
+ * `isTTY` flag. The CLI delegates to this so the read-only status line
21
+ * does not need to know about ANSI/NO_COLOR semantics.
22
+ *
23
+ * The optional `forced` argument is a TEST SEAM (consumed by direct unit
24
+ * tests, not by the CLI). It lets tests pin a tier without constructing a
25
+ * TTY/NO_COLOR fixture. The CLI does NOT expose any flag that maps to
26
+ * `forced` — the env-driven path is the single first-version runtime
27
+ * source of truth.
28
+ *
29
+ * Adapter-internal env override (NOT a user-facing CLI flag):
30
+ * `PEAKS_STATUSLINE_ASCII` — when set to `1` / `true` / `yes`, the
31
+ * renderer drops to the ASCII palette (no Unicode-extra glyphs, no ANSI).
32
+ * This is an adapter-internal mechanism for the trust boundary described
33
+ * by the two-forms-only / human-NL-choice-only tenets: the user never
34
+ * types a CLI verb to flip palettes, but the adapter (and only the
35
+ * adapter) can set the env var on the user's behalf when its consumer
36
+ * (e.g. a tiny terminal without UTF-8) needs ASCII. We do NOT expose
37
+ * this via a CLI flag.
38
+ *
39
+ * Order of resolution (highest priority first):
40
+ *
41
+ * 1. `forced` argument (test seam only)
42
+ * 2. `PEAKS_STATUSLINE_ASCII` set → `ascii` (adapter-internal override)
43
+ * 3. `isTTY === true` → `ansi-unicode`
44
+ * 4. otherwise → `unicode` (still ANSI-colored; only `ascii` is color-free)
45
+ *
46
+ * Why this ordering: the IDE statusline invokes the renderer through a
47
+ * hook that may not always be detected as a TTY. The `unicode` tier
48
+ * now emits the cyan brand accent + semantic colors so the line reads
49
+ * as a branded product surface whether or not the consumer is a
50
+ * terminal. `NO_COLOR` is honored as an ambient convention by the
51
+ * IDE adapter (which can set `PEAKS_STATUSLINE_ASCII=1` to opt out
52
+ * fully), so a separate tier for NO_COLOR is no longer needed.
53
+ *
54
+ * `ascii` is the strict no-color, no-Unicode-extra tier; it is the
55
+ * single safe option for plain log / file consumers.
56
+ *
57
+ * Why `PEAKS_STATUSLINE_ASCII` outranks `NO_COLOR`:
58
+ * The two env vars are NOT redundant. `NO_COLOR` (https://no-color.org)
59
+ * is the cross-industry signal that NO escape sequences should be emitted
60
+ * — its only effect is "ANSI off". The Unicode-extra glyphs (●, █, ░, etc.)
61
+ * are NOT ANSI escape sequences; they are UTF-8 characters and `NO_COLOR`
62
+ * does not address them. `PEAKS_STATUSLINE_ASCII` is the adapter-internal
63
+ * "signal source is a tiny terminal without UTF-8" override and drops the
64
+ * glyphs to ASCII shape as well. Both are correct signals about
65
+ * different concerns; the ASCII override wins because it is strictly
66
+ * narrower (a tiny terminal needs both no-ANSI AND no-Unicode-extra).
67
+ *
68
+ * The deliberate ordering keeps the rendered text ANSI-free when the consumer
69
+ * is a logger, a file, or any non-interactive sink, and only enables ANSI
70
+ * under an explicit supported condition (TTY + no env veto).
71
+ */
72
+ export declare function resolveStatusLineCapability(input: {
73
+ readonly env: NodeJS.ProcessEnv;
74
+ readonly isTTY: boolean;
75
+ readonly forced?: StatusLineCapability;
76
+ }): StatusLineCapability;
77
+ /**
78
+ * Render the status line. Pure — no I/O, no side effects. The output is
79
+ * a single short line suitable for the bottom-of-terminal status bar.
80
+ *
81
+ * The `options.capability` field selects the glyph palette. When omitted,
82
+ * the function falls back to `unicode` (no ANSI escape codes), preserving
83
+ * the legacy behaviour for the CLI's `peaks statusline` invocation.
84
+ *
85
+ * Compact-state precedence: when `model.compact.kind !== 'none'` the
86
+ * compact bar replaces the active/idle/stale content. The C1 baseline
87
+ * line is preserved when the compact state is `none`.
88
+ */
89
+ export declare function renderStatusLine(model: StatusLineModel, options?: StatusLineRenderOptions): string;