claude-code-session-manager 0.75.3 → 0.77.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/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
- package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/lib/activeSessions.cjs +116 -6
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +269 -96
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +247 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +25 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +64 -5
- package/src/main/ipcSchemas.cjs +69 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +69 -2
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +514 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +520 -21
- package/src/main/lib/jobWorktree.cjs +2 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +370 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +106 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +103 -15
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +40 -7
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +54 -4
- package/src/main/lib/reaperHelpers.cjs +64 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +301 -55
- package/src/main/lib/schedulerConfig.cjs +99 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +162 -3
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1701 -130
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +187 -3
- package/src/preload/index.cjs +9 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
- package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
- package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-worktree-cap-defer.test.cjs — PRD 1112: a job that cannot get
|
|
3
|
+
* worktree isolation because the concurrency cap is currently exhausted must
|
|
4
|
+
* be DEFERRED (left 'pending', retried on the next dispatch pass), never
|
|
5
|
+
* spawned in a shared working tree the way every OTHER worktree.ok===false
|
|
6
|
+
* reason (e.g. "not a git repository") still falls back to running in place.
|
|
7
|
+
*
|
|
8
|
+
* Drives spawnJob end-to-end against a real git fixture repo, forcing the
|
|
9
|
+
* cap via SM_JOB_WORKTREE_MAX=1 plus a real occupying worktree (so the cap
|
|
10
|
+
* check itself, not a mock, is what defers the job).
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-worktree-cap-defer.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { execFileSync } = require('node:child_process');
|
|
22
|
+
|
|
23
|
+
let tmpHome;
|
|
24
|
+
let originalHome;
|
|
25
|
+
let originalMax;
|
|
26
|
+
let spawnJob;
|
|
27
|
+
let gitWorktree;
|
|
28
|
+
|
|
29
|
+
function git(args, cwd) {
|
|
30
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function initRepo(dir) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
git(['init', '-q'], dir);
|
|
36
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
37
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
38
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
39
|
+
git(['add', '-A'], dir);
|
|
40
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// See scheduler-quiet-machine-lease.test.cjs's identical helper: readQueue()'s
|
|
44
|
+
// per-project discovery walks ~/.claude/projects/*/*.jsonl transcripts for a
|
|
45
|
+
// `cwd` field to find known project queue.json shards.
|
|
46
|
+
function registerActiveProject(cwd) {
|
|
47
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
48
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
49
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
50
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeProjectQueue(cwd, jobs) {
|
|
54
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
55
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
56
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
57
|
+
return path.join(stateDir, 'queue.json');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Stub `claude` binary: writes a marker file into its OWN process.cwd() so a
|
|
61
|
+
// test can prove whether — and where — it was actually spawned, then emits a
|
|
62
|
+
// clean stream-json success result.
|
|
63
|
+
function writeClaudeStub() {
|
|
64
|
+
const stubPath = path.join(os.tmpdir(), `sm-claude-stub-capdefer-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
65
|
+
const body = `
|
|
66
|
+
const fs = require('fs');
|
|
67
|
+
const path = require('path');
|
|
68
|
+
const { execFileSync } = require('child_process');
|
|
69
|
+
fs.writeFileSync(path.join(process.cwd(), 'ran-here.marker'), 'yes', 'utf8');
|
|
70
|
+
// Land a real commit + finish-protocol sentinel when possible, so a run
|
|
71
|
+
// that actually reaches the finish protocol lands 'completed' — a
|
|
72
|
+
// best-effort no-op when the cwd isn't a git repo (e.g. the non-git
|
|
73
|
+
// fallback case), where the run parks in needs_review instead, which is
|
|
74
|
+
// fine — this test only cares whether it ran in place, not its verdict.
|
|
75
|
+
try {
|
|
76
|
+
execFileSync('git', ['add', '-A'], { cwd: process.cwd() });
|
|
77
|
+
execFileSync('git', ['commit', '-q', '-m', 'stub commit'], { cwd: process.cwd() });
|
|
78
|
+
} catch { /* not a git repo — best-effort only */ }
|
|
79
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
|
|
80
|
+
process.exit(0);
|
|
81
|
+
`;
|
|
82
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
83
|
+
return stubPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beforeAll(() => {
|
|
87
|
+
originalHome = process.env.HOME;
|
|
88
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-'));
|
|
89
|
+
process.env.HOME = tmpHome;
|
|
90
|
+
originalMax = process.env.SM_JOB_WORKTREE_MAX;
|
|
91
|
+
({ spawnJob } = require('../scheduler.cjs'));
|
|
92
|
+
gitWorktree = require('../lib/gitWorktree.cjs');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(() => {
|
|
96
|
+
process.env.HOME = originalHome;
|
|
97
|
+
if (originalMax === undefined) delete process.env.SM_JOB_WORKTREE_MAX;
|
|
98
|
+
else process.env.SM_JOB_WORKTREE_MAX = originalMax;
|
|
99
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
104
|
+
delete process.env.SM_JOB_WORKTREE_MAX;
|
|
105
|
+
gitWorktree._resetActiveWorktreeCountForTests('job', 0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a job held behind the worktree cap stays pending with a heldReason, is never spawned in place, and dispatches once the cap frees up', async () => {
|
|
109
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-project-'));
|
|
110
|
+
initRepo(projectCwd);
|
|
111
|
+
registerActiveProject(projectCwd);
|
|
112
|
+
|
|
113
|
+
const slug = `1112-test-held-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
114
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
115
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
116
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Held behind the worktree cap.', 'utf8');
|
|
117
|
+
const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
|
|
118
|
+
|
|
119
|
+
process.env.SM_JOB_WORKTREE_MAX = '1';
|
|
120
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
121
|
+
|
|
122
|
+
// Occupy the single worktree slot with a REAL checkout, so the cap check
|
|
123
|
+
// spawnJob relies on is exercising the actual gitWorktree plumbing, not a
|
|
124
|
+
// mock.
|
|
125
|
+
const occupier = await gitWorktree.createJobWorktree({ cwd: projectCwd, slug: 'occupier' });
|
|
126
|
+
expect(occupier.ok).toBe(true);
|
|
127
|
+
|
|
128
|
+
const runId = `run-${slug}`;
|
|
129
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
130
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
|
|
134
|
+
|
|
135
|
+
// Held: still pending, never spawned, heldReason visible on the row.
|
|
136
|
+
let jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
137
|
+
let row = jobs.find((j) => j.slug === slug);
|
|
138
|
+
expect(row.status).toBe('pending');
|
|
139
|
+
expect(row.heldReason).toMatch(/worktree cap reached/);
|
|
140
|
+
expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(false);
|
|
141
|
+
expect(fs.existsSync(path.join(occupier.dir, 'ran-here.marker'))).toBe(false);
|
|
142
|
+
|
|
143
|
+
// Free the cap (mirrors cleanupJobWorktree releasing the counter), then
|
|
144
|
+
// retry — the held job dispatches on this next pass, exactly like a
|
|
145
|
+
// sessionSlots-blocked job retries once a slot frees up.
|
|
146
|
+
await gitWorktree.cleanupJobWorktree({ cwd: projectCwd, dir: occupier.dir, branch: occupier.branch });
|
|
147
|
+
|
|
148
|
+
await spawnJob({ slug, cwd: projectCwd }, `${runId}-2`, runDir, projectCwd);
|
|
149
|
+
|
|
150
|
+
jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
151
|
+
row = jobs.find((j) => j.slug === slug);
|
|
152
|
+
expect(row.status).toBe('completed');
|
|
153
|
+
expect(row.heldReason).toBeUndefined();
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
156
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}, 30_000);
|
|
159
|
+
|
|
160
|
+
test('a non-git cwd still falls back to running in place (unchanged behaviour — only cap-reached is a deferral)', async () => {
|
|
161
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-worktree-cap-defer-nongit-'));
|
|
162
|
+
fs.mkdirSync(projectCwd, { recursive: true }); // deliberately NOT a git repo
|
|
163
|
+
registerActiveProject(projectCwd);
|
|
164
|
+
|
|
165
|
+
const slug = `1112-test-nongit-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
166
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
167
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
168
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Non-git cwd, must run in place.', 'utf8');
|
|
169
|
+
const queuePath = writeProjectQueue(projectCwd, [{ slug, status: 'pending', cwd: projectCwd }]);
|
|
170
|
+
|
|
171
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
172
|
+
|
|
173
|
+
const runId = `run-${slug}`;
|
|
174
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
175
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await spawnJob({ slug, cwd: projectCwd }, runId, runDir, projectCwd);
|
|
179
|
+
|
|
180
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
181
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
182
|
+
// Not held/deferred — it actually ran (a non-git cwd can't commit, so it
|
|
183
|
+
// parks in needs_review rather than completed; this test only cares that
|
|
184
|
+
// it was dispatched in place, not its exact verdict).
|
|
185
|
+
expect(row.status).not.toBe('pending');
|
|
186
|
+
expect(row.heldReason).toBeUndefined();
|
|
187
|
+
expect(row.worktreeFallbackReason).toMatch(/not a git repository/);
|
|
188
|
+
// Ran IN PLACE — the marker landed directly in projectCwd, not a worktree.
|
|
189
|
+
expect(fs.existsSync(path.join(projectCwd, 'ran-here.marker'))).toBe(true);
|
|
190
|
+
} finally {
|
|
191
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
192
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
}, 30_000);
|
|
@@ -54,22 +54,20 @@ function silentLogger() {
|
|
|
54
54
|
return { log: () => {}, warn: () => {} };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
await seedAgentPersonas({ logger: silentLogger() });
|
|
57
|
+
const ALL_PERSONAS = ['architect', 'dev-lead', 'project-home-builder'];
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
expect(fs.existsSync(architect)).toBe(true);
|
|
63
|
-
expect(fs.existsSync(devLead)).toBe(true);
|
|
59
|
+
test('fresh seed writes every persona file', async () => {
|
|
60
|
+
await seedAgentPersonas({ logger: silentLogger() });
|
|
64
61
|
|
|
65
|
-
const
|
|
66
|
-
path.join(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
for (const name of ALL_PERSONAS) {
|
|
63
|
+
const dest = path.join(agentsDir(), `${name}.md`);
|
|
64
|
+
expect(fs.existsSync(dest)).toBe(true);
|
|
65
|
+
const bundled = fs.readFileSync(path.join(__dirname, '..', '..', 'seed', 'agents', `${name}.md`), 'utf8');
|
|
66
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe(bundled);
|
|
67
|
+
}
|
|
70
68
|
|
|
71
69
|
const marker = JSON.parse(fs.readFileSync(markerPath(), 'utf8'));
|
|
72
|
-
expect(marker.
|
|
70
|
+
expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
|
|
73
71
|
});
|
|
74
72
|
|
|
75
73
|
test('a pre-existing persona file is left byte-identical', async () => {
|
|
@@ -81,20 +79,83 @@ test('a pre-existing persona file is left byte-identical', async () => {
|
|
|
81
79
|
await seedAgentPersonas({ logger: silentLogger() });
|
|
82
80
|
|
|
83
81
|
expect(fs.readFileSync(architect, 'utf8')).toBe(customContent);
|
|
84
|
-
// dev-lead had no pre-existing file, so
|
|
82
|
+
// dev-lead and project-home-builder had no pre-existing file, so they should still be seeded.
|
|
85
83
|
expect(fs.existsSync(path.join(agentsDir(), 'dev-lead.md'))).toBe(true);
|
|
84
|
+
expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
|
|
86
85
|
});
|
|
87
86
|
|
|
88
|
-
test('done:true marker
|
|
87
|
+
test('a machine already fully seeded under the old done:true marker only picks up a newly added persona', async () => {
|
|
89
88
|
const mPath = markerPath();
|
|
90
89
|
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
90
|
+
// Legacy marker shape from before the seeded-set change — no `seeded` array.
|
|
91
91
|
fs.writeFileSync(mPath, JSON.stringify({ done: true, attempts: 1, ts: '2026-01-01T00:00:00.000Z' }));
|
|
92
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
93
|
+
fs.writeFileSync(path.join(agentsDir(), 'architect.md'), 'old architect content\n');
|
|
94
|
+
fs.writeFileSync(path.join(agentsDir(), 'dev-lead.md'), 'old dev-lead content\n');
|
|
95
|
+
|
|
96
|
+
await seedAgentPersonas({ logger: silentLogger() });
|
|
97
|
+
|
|
98
|
+
// Pre-existing files from the old run are untouched...
|
|
99
|
+
expect(fs.readFileSync(path.join(agentsDir(), 'architect.md'), 'utf8')).toBe('old architect content\n');
|
|
100
|
+
expect(fs.readFileSync(path.join(agentsDir(), 'dev-lead.md'), 'utf8')).toBe('old dev-lead content\n');
|
|
101
|
+
// ...but the newly added persona is delivered.
|
|
102
|
+
expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
|
|
103
|
+
|
|
104
|
+
const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
|
|
105
|
+
expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a machine already seeded under the new seeded-set marker picks up a newly added persona', async () => {
|
|
109
|
+
const mPath = markerPath();
|
|
110
|
+
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
111
|
+
fs.writeFileSync(mPath, JSON.stringify({ seeded: ['architect', 'dev-lead'], attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
112
|
+
|
|
113
|
+
await seedAgentPersonas({ logger: silentLogger() });
|
|
114
|
+
|
|
115
|
+
expect(fs.existsSync(path.join(agentsDir(), 'project-home-builder.md'))).toBe(true);
|
|
116
|
+
const marker = JSON.parse(fs.readFileSync(mPath, 'utf8'));
|
|
117
|
+
expect(marker.seeded.sort()).toEqual([...ALL_PERSONAS].sort());
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a machine with every known persona already in the seeded set short-circuits without touching agents dir', async () => {
|
|
121
|
+
const mPath = markerPath();
|
|
122
|
+
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
123
|
+
fs.writeFileSync(mPath, JSON.stringify({ seeded: ALL_PERSONAS, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
92
124
|
|
|
93
125
|
await seedAgentPersonas({ logger: silentLogger() });
|
|
94
126
|
|
|
95
127
|
expect(fs.existsSync(agentsDir())).toBe(false);
|
|
96
128
|
});
|
|
97
129
|
|
|
130
|
+
test('a corrupt/unparseable marker file still seeds correctly', async () => {
|
|
131
|
+
const mPath = markerPath();
|
|
132
|
+
fs.mkdirSync(path.dirname(mPath), { recursive: true });
|
|
133
|
+
fs.writeFileSync(mPath, '{ not valid json');
|
|
134
|
+
|
|
135
|
+
await seedAgentPersonas({ logger: silentLogger() });
|
|
136
|
+
|
|
137
|
+
for (const name of ALL_PERSONAS) {
|
|
138
|
+
expect(fs.existsSync(path.join(agentsDir(), `${name}.md`))).toBe(true);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('the bundled project-home-builder persona contains no session-manager-repo paths', () => {
|
|
143
|
+
const content = fs.readFileSync(
|
|
144
|
+
path.join(__dirname, '..', '..', 'seed', 'agents', 'project-home-builder.md'),
|
|
145
|
+
'utf8'
|
|
146
|
+
);
|
|
147
|
+
const forbidden = [
|
|
148
|
+
'session-manager-operations/architecture/',
|
|
149
|
+
'.claude/agents/',
|
|
150
|
+
'session-manager-operations/design-mocks/',
|
|
151
|
+
'scripts/',
|
|
152
|
+
'npm run build:project-pages',
|
|
153
|
+
];
|
|
154
|
+
for (const substr of forbidden) {
|
|
155
|
+
expect(content).not.toContain(substr);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
98
159
|
test('SM_SEED_AGENT_PERSONAS_DISABLE=1 short-circuits', async () => {
|
|
99
160
|
process.env.SM_SEED_AGENT_PERSONAS_DISABLE = '1';
|
|
100
161
|
|
|
@@ -143,3 +143,69 @@ test('a failed attempt bumps the counter and stops after MAX_ATTEMPTS', async ()
|
|
|
143
143
|
expect(called).toBe(false);
|
|
144
144
|
expect(readMarker()).toMatchObject({ done: false, attempts: 3 });
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────── repair: marker done, script path stale
|
|
148
|
+
|
|
149
|
+
test('marker done + user-scope script path missing -> removes then re-adds, marker stays done', async () => {
|
|
150
|
+
fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
|
|
151
|
+
fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
152
|
+
fs.writeFileSync(
|
|
153
|
+
claudeJsonPath(),
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
mcpServers: {
|
|
156
|
+
'session-manager-scheduler': { type: 'stdio', command: 'node', args: ['/definitely/does/not/exist.cjs'] },
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const removeCalls = [];
|
|
162
|
+
const removeFn = async () => { removeCalls.push(1); return { ok: true }; };
|
|
163
|
+
const addCalls = [];
|
|
164
|
+
const addFn = async (serverPath) => { addCalls.push(serverPath); return { ok: true, exitCode: 0 }; };
|
|
165
|
+
|
|
166
|
+
await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
|
|
167
|
+
|
|
168
|
+
expect(removeCalls).toHaveLength(1);
|
|
169
|
+
expect(addCalls).toHaveLength(1);
|
|
170
|
+
expect(path.isAbsolute(addCalls[0])).toBe(true);
|
|
171
|
+
expect(readMarker().done).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('marker done + user-scope entry ABSENT (deliberate remove) stays removed, no repair attempted', async () => {
|
|
175
|
+
fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
|
|
176
|
+
fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
177
|
+
// No ~/.claude.json at all — same as a deliberate `claude mcp remove`.
|
|
178
|
+
|
|
179
|
+
let removeCalled = false;
|
|
180
|
+
let addCalled = false;
|
|
181
|
+
const removeFn = async () => { removeCalled = true; return { ok: true }; };
|
|
182
|
+
const addFn = async () => { addCalled = true; return { ok: true }; };
|
|
183
|
+
|
|
184
|
+
await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
|
|
185
|
+
|
|
186
|
+
expect(removeCalled).toBe(false);
|
|
187
|
+
expect(addCalled).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('marker done + user-scope script path exists -> healthy, no repair attempted', async () => {
|
|
191
|
+
fs.mkdirSync(path.dirname(markerPath()), { recursive: true });
|
|
192
|
+
fs.writeFileSync(markerPath(), JSON.stringify({ done: true, attempts: 0, ts: '2026-01-01T00:00:00.000Z' }));
|
|
193
|
+
fs.writeFileSync(
|
|
194
|
+
claudeJsonPath(),
|
|
195
|
+
JSON.stringify({
|
|
196
|
+
mcpServers: {
|
|
197
|
+
'session-manager-scheduler': { type: 'stdio', command: 'node', args: [markerPath()] }, // any existing file
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
let removeCalled = false;
|
|
203
|
+
let addCalled = false;
|
|
204
|
+
const removeFn = async () => { removeCalled = true; return { ok: true }; };
|
|
205
|
+
const addFn = async () => { addCalled = true; return { ok: true }; };
|
|
206
|
+
|
|
207
|
+
await seedSchedulerMcp({ logger: silentLogger(), addFn, removeFn });
|
|
208
|
+
|
|
209
|
+
expect(removeCalled).toBe(false);
|
|
210
|
+
expect(addCalled).toBe(false);
|
|
211
|
+
});
|
|
@@ -101,11 +101,20 @@ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
|
|
|
101
101
|
// like anything else with no blocking dependsOn — the 828 row is no longer
|
|
102
102
|
// held back merely for carrying a higher number.
|
|
103
103
|
it('legacy same-NN pending jobs are eligible alongside higher-numbered ones', () => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
// Predates the per-project job cap (PRD 1066, default 2); raise it so
|
|
105
|
+
// this test keeps exercising parallelGroup semantics, not the cap.
|
|
106
|
+
const prevCap = process.env.SM_PROJECT_JOB_CAP;
|
|
107
|
+
process.env.SM_PROJECT_JOB_CAP = '10';
|
|
108
|
+
try {
|
|
109
|
+
const a = job('827-one', 'pending', 827);
|
|
110
|
+
const b = job('827-two', 'pending', 827);
|
|
111
|
+
const later = job('828-three', 'pending', 828);
|
|
112
|
+
const res = pickForProject([a, b, later], new Set(), 3);
|
|
113
|
+
expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two', '828-three']);
|
|
114
|
+
} finally {
|
|
115
|
+
if (prevCap === undefined) delete process.env.SM_PROJECT_JOB_CAP;
|
|
116
|
+
else process.env.SM_PROJECT_JOB_CAP = prevCap;
|
|
117
|
+
}
|
|
109
118
|
});
|
|
110
119
|
});
|
|
111
120
|
|
package/src/main/bilkoHost.cjs
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
const { ipcMain } = require('electron');
|
|
32
|
+
const { opsPath } = require('./lib/opsOwnership.cjs');
|
|
32
33
|
const fsp = require('node:fs/promises');
|
|
33
34
|
const path = require('node:path');
|
|
34
35
|
const crypto = require('node:crypto');
|
|
@@ -46,7 +47,7 @@ const WRITER = 'bilko-host';
|
|
|
46
47
|
const PROJECT_PAGE_LENSES = new Set(['home', 'marketing', 'feature', 'architecture']);
|
|
47
48
|
|
|
48
49
|
function bilkoHostDir(cwd) {
|
|
49
|
-
return
|
|
50
|
+
return opsPath(cwd, 'bilko-host');
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
function distDir(cwd) {
|
|
@@ -103,7 +104,7 @@ async function get({ cwd }) {
|
|
|
103
104
|
const realCwd = config.validatePath(cwd);
|
|
104
105
|
|
|
105
106
|
const marketingResult = await config.readText(
|
|
106
|
-
|
|
107
|
+
opsPath(realCwd, 'project-pages', 'output', 'marketing.html'),
|
|
107
108
|
);
|
|
108
109
|
const pkgResult = await config.readJson(path.join(realCwd, 'package.json'));
|
|
109
110
|
const pkg = pkgResult.exists && !pkgResult.parseError ? pkgResult.data : null;
|
|
@@ -215,7 +216,7 @@ async function removeDocument({ cwd, id }) {
|
|
|
215
216
|
async function resolveDocumentHtml(cwd, doc) {
|
|
216
217
|
if (doc.source.kind === 'project-page-lens') {
|
|
217
218
|
const result = await config.readText(
|
|
218
|
-
|
|
219
|
+
opsPath(cwd, 'project-pages', 'output', `${doc.source.lens}.html`),
|
|
219
220
|
);
|
|
220
221
|
if (!result.exists) {
|
|
221
222
|
throw new Error(`document "${doc.title}" points at the ${doc.source.lens} Project Page, but it hasn't been generated yet`);
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -518,7 +518,12 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
518
518
|
// resolveSourcePromptIdFromClaudeSession. sessionId IS the Epic's
|
|
519
519
|
// claudeSessionId for an Epic-backed tab (domain model: Epic:claude-session
|
|
520
520
|
// 1:1); for a non-Epic tab, resolution below simply finds no match.
|
|
521
|
-
|
|
521
|
+
// SM_PROJECT_ROOT is the real project cwd (never the worktree spawn cwd
|
|
522
|
+
// resolved below) — scheduler-mcp-server.cjs forwards it as
|
|
523
|
+
// originProjectRoot on create-prd/open-session/readiness calls so those
|
|
524
|
+
// routes can resolve the right project even from a worktree pwd. See
|
|
525
|
+
// projectRootResolve.cjs.
|
|
526
|
+
const childEnv = cleanChildEnv({ PATH: pathWithUserBins(), SM_CHAT_SESSION_ID: sessionId, SM_PROJECT_ROOT: cwd });
|
|
522
527
|
|
|
523
528
|
// Prepend the stop-signal protocol instruction and the chat-mode truth
|
|
524
529
|
// instruction to every prompt
|
package/src/main/config.cjs
CHANGED
|
@@ -19,7 +19,7 @@ const fs = require('node:fs');
|
|
|
19
19
|
const fsp = require('node:fs/promises');
|
|
20
20
|
const path = require('node:path');
|
|
21
21
|
const os = require('node:os');
|
|
22
|
-
const { assertOpsWrite } = require('./lib/opsOwnership.cjs');
|
|
22
|
+
const { assertOpsWrite, opsPath } = require('./lib/opsOwnership.cjs');
|
|
23
23
|
const chokidar = require('chokidar');
|
|
24
24
|
const logs = require('./logs.cjs');
|
|
25
25
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
@@ -139,39 +139,31 @@ function validateWrite(realAbs) {
|
|
|
139
139
|
if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
|
|
140
140
|
return;
|
|
141
141
|
}
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const briefSub = path.join(realRoot, 'session-manager-operations', 'project-brief');
|
|
165
|
-
if (realAbs === briefSub || realAbs.startsWith(briefSub + path.sep)) {
|
|
166
|
-
return;
|
|
142
|
+
// Writable ops namespaces under a registered project root — this
|
|
143
|
+
// repo's per-project artifact-store convention:
|
|
144
|
+
// prompt-sessions PromptSession persistence (promptSessions.ts) and
|
|
145
|
+
// the scheduler's read-modify-write of active-index
|
|
146
|
+
// (promptSessionEvents.cjs, PRD 814)
|
|
147
|
+
// scheduler PRD sources + Epic PRD dirs + state shards
|
|
148
|
+
// (PRD 825; scheduler.cjs remote.writePrd)
|
|
149
|
+
// project-brief projectBrief.cjs's brief.json (PRD 837)
|
|
150
|
+
// bilko-host bilkoHost.cjs's bundle prep
|
|
151
|
+
// project-pages projectHomeAdminRoutes.cjs's app-side render path
|
|
152
|
+
// (summary.json/picks.json/output/*; PRD:
|
|
153
|
+
// project-home-admin-routes)
|
|
154
|
+
// Paths come from opsOwnership.opsPath (PRD 1082) — the one ops-root
|
|
155
|
+
// resolver — which THROWS for an ephemeral root (a linked worktree that
|
|
156
|
+
// pty.cjs registered as an allowed root for its spawn cwd, or
|
|
157
|
+
// os.tmpdir()). That throw is the point: a worktree's ops subtree is
|
|
158
|
+
// never a writable destination, so its exemptions simply do not exist.
|
|
159
|
+
let opsSubs;
|
|
160
|
+
try {
|
|
161
|
+
opsSubs = ['prompt-sessions', 'scheduler', 'project-brief', 'bilko-host', 'project-pages'].map((ns) => opsPath(realRoot, ns));
|
|
162
|
+
} catch {
|
|
163
|
+
opsSubs = [];
|
|
167
164
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// session-manager-operations/bilko-host/, this repo's existing
|
|
171
|
-
// per-project artifact-store convention.
|
|
172
|
-
const bilkoHostSub = path.join(realRoot, 'session-manager-operations', 'bilko-host');
|
|
173
|
-
if (realAbs === bilkoHostSub || realAbs.startsWith(bilkoHostSub + path.sep)) {
|
|
174
|
-
return;
|
|
165
|
+
for (const sub of opsSubs) {
|
|
166
|
+
if (realAbs === sub || realAbs.startsWith(sub + path.sep)) return;
|
|
175
167
|
}
|
|
176
168
|
}
|
|
177
169
|
}
|