@asc-agent/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +22 -0
- package/dist/adapters/claude-code/binding.d.ts +5 -0
- package/dist/adapters/claude-code/binding.js +14 -0
- package/dist/adapters/claude-code/guard.d.ts +54 -0
- package/dist/adapters/claude-code/guard.js +295 -0
- package/dist/adapters/claude-code/install.d.ts +67 -0
- package/dist/adapters/claude-code/install.js +231 -0
- package/dist/adapters/claude-code/observer.d.ts +38 -0
- package/dist/adapters/claude-code/observer.js +77 -0
- package/dist/adapters/claude-code/probe.d.ts +53 -0
- package/dist/adapters/claude-code/probe.js +129 -0
- package/dist/adapters/claude-code/skill.d.ts +8 -0
- package/dist/adapters/claude-code/skill.js +267 -0
- package/dist/adapters/fixture-work/index.d.ts +34 -0
- package/dist/adapters/fixture-work/index.js +78 -0
- package/dist/adapters/github/adapter.d.ts +32 -0
- package/dist/adapters/github/adapter.js +95 -0
- package/dist/adapters/github/client.d.ts +30 -0
- package/dist/adapters/github/client.js +90 -0
- package/dist/adapters/github/context.d.ts +44 -0
- package/dist/adapters/github/context.js +170 -0
- package/dist/adapters/github/event-source.d.ts +55 -0
- package/dist/adapters/github/event-source.js +183 -0
- package/dist/adapters/github/scm.d.ts +50 -0
- package/dist/adapters/github/scm.js +104 -0
- package/dist/adapters/gitlab/adapter.d.ts +36 -0
- package/dist/adapters/gitlab/adapter.js +117 -0
- package/dist/adapters/gitlab/client.d.ts +30 -0
- package/dist/adapters/gitlab/client.js +47 -0
- package/dist/adapters/gitlab/ports.d.ts +40 -0
- package/dist/adapters/gitlab/ports.js +186 -0
- package/dist/adapters/jam/adapter.d.ts +53 -0
- package/dist/adapters/jam/adapter.js +144 -0
- package/dist/adapters/jam/event-source.d.ts +28 -0
- package/dist/adapters/jam/event-source.js +65 -0
- package/dist/adapters/jam/mcp-client.d.ts +50 -0
- package/dist/adapters/jam/mcp-client.js +218 -0
- package/dist/adapters/jam/ports.d.ts +51 -0
- package/dist/adapters/jam/ports.js +167 -0
- package/dist/adapters/local/identity.d.ts +27 -0
- package/dist/adapters/local/identity.js +40 -0
- package/dist/adapters/local/presentation.d.ts +14 -0
- package/dist/adapters/local/presentation.js +42 -0
- package/dist/adapters/markdown/layout.d.ts +15 -0
- package/dist/adapters/markdown/layout.js +49 -0
- package/dist/adapters/markdown/serialize.d.ts +9 -0
- package/dist/adapters/markdown/serialize.js +120 -0
- package/dist/adapters/markdown/state-store.d.ts +23 -0
- package/dist/adapters/markdown/state-store.js +316 -0
- package/dist/adapters/mattermost/client.d.ts +23 -0
- package/dist/adapters/mattermost/client.js +54 -0
- package/dist/adapters/mattermost/presentation.d.ts +43 -0
- package/dist/adapters/mattermost/presentation.js +88 -0
- package/dist/adapters/memory/mocks.d.ts +87 -0
- package/dist/adapters/memory/mocks.js +163 -0
- package/dist/adapters/memory/runtime-binding.d.ts +23 -0
- package/dist/adapters/memory/runtime-binding.js +118 -0
- package/dist/adapters/memory/state-store.d.ts +17 -0
- package/dist/adapters/memory/state-store.js +125 -0
- package/dist/adapters/text/renderer.d.ts +7 -0
- package/dist/adapters/text/renderer.js +81 -0
- package/dist/adapters/webhook/ingress.d.ts +103 -0
- package/dist/adapters/webhook/ingress.js +150 -0
- package/dist/cli/asc.d.ts +16 -0
- package/dist/cli/asc.js +2914 -0
- package/dist/cli/identity-config.d.ts +7 -0
- package/dist/cli/identity-config.js +31 -0
- package/dist/composition/observe.d.ts +21 -0
- package/dist/composition/observe.js +72 -0
- package/dist/composition/registry.d.ts +33 -0
- package/dist/composition/registry.js +62 -0
- package/dist/composition/runtime.d.ts +63 -0
- package/dist/composition/runtime.js +155 -0
- package/dist/core/approval/service.d.ts +17 -0
- package/dist/core/approval/service.js +129 -0
- package/dist/core/attach/bootstrap.d.ts +102 -0
- package/dist/core/attach/bootstrap.js +178 -0
- package/dist/core/attach/init.d.ts +29 -0
- package/dist/core/attach/init.js +100 -0
- package/dist/core/attach/setup-plan.d.ts +125 -0
- package/dist/core/attach/setup-plan.js +177 -0
- package/dist/core/attach/setup.d.ts +40 -0
- package/dist/core/attach/setup.js +140 -0
- package/dist/core/binding/types.d.ts +93 -0
- package/dist/core/binding/types.js +74 -0
- package/dist/core/distribution/release.d.ts +15 -0
- package/dist/core/distribution/release.js +27 -0
- package/dist/core/distribution/runtime-install.d.ts +50 -0
- package/dist/core/distribution/runtime-install.js +90 -0
- package/dist/core/distribution/runtime-select.d.ts +74 -0
- package/dist/core/distribution/runtime-select.js +149 -0
- package/dist/core/execution/executor.d.ts +51 -0
- package/dist/core/execution/executor.js +106 -0
- package/dist/core/execution/grant.d.ts +42 -0
- package/dist/core/execution/grant.js +78 -0
- package/dist/core/model/entities.d.ts +872 -0
- package/dist/core/model/entities.js +285 -0
- package/dist/core/model/ids.d.ts +41 -0
- package/dist/core/model/ids.js +50 -0
- package/dist/core/model/transitions.d.ts +26 -0
- package/dist/core/model/transitions.js +124 -0
- package/dist/core/monitor/coverage.d.ts +111 -0
- package/dist/core/monitor/coverage.js +129 -0
- package/dist/core/monitor/engine.d.ts +134 -0
- package/dist/core/monitor/engine.js +574 -0
- package/dist/core/monitor/health-alerts.d.ts +35 -0
- package/dist/core/monitor/health-alerts.js +88 -0
- package/dist/core/monitor/investigation.d.ts +82 -0
- package/dist/core/monitor/investigation.js +232 -0
- package/dist/core/monitor/observation.d.ts +79 -0
- package/dist/core/monitor/observation.js +110 -0
- package/dist/core/monitor/relevance.d.ts +47 -0
- package/dist/core/monitor/relevance.js +100 -0
- package/dist/core/monitor/signals.d.ts +59 -0
- package/dist/core/monitor/signals.js +105 -0
- package/dist/core/operator/local-operator.d.ts +60 -0
- package/dist/core/operator/local-operator.js +82 -0
- package/dist/core/operator/preflight.d.ts +60 -0
- package/dist/core/operator/preflight.js +139 -0
- package/dist/core/operator/proceed.d.ts +98 -0
- package/dist/core/operator/proceed.js +167 -0
- package/dist/core/operator/progress.d.ts +124 -0
- package/dist/core/operator/progress.js +135 -0
- package/dist/core/operator/render.d.ts +34 -0
- package/dist/core/operator/render.js +170 -0
- package/dist/core/operator/runtime-binding.d.ts +61 -0
- package/dist/core/operator/runtime-binding.js +22 -0
- package/dist/core/policy/ownership.d.ts +44 -0
- package/dist/core/policy/ownership.js +46 -0
- package/dist/core/policy/policy.d.ts +99 -0
- package/dist/core/policy/policy.js +147 -0
- package/dist/core/policy/remote-freeze.d.ts +101 -0
- package/dist/core/policy/remote-freeze.js +151 -0
- package/dist/core/policy/scope.d.ts +23 -0
- package/dist/core/policy/scope.js +93 -0
- package/dist/core/presentation/digest.d.ts +98 -0
- package/dist/core/presentation/digest.js +160 -0
- package/dist/core/resolver/load.d.ts +121 -0
- package/dist/core/resolver/load.js +246 -0
- package/dist/core/resolver/render.d.ts +4 -0
- package/dist/core/resolver/render.js +62 -0
- package/dist/core/resolver/resolve.d.ts +42 -0
- package/dist/core/resolver/resolve.js +51 -0
- package/dist/core/resolver/version.d.ts +22 -0
- package/dist/core/resolver/version.js +49 -0
- package/dist/core/runtime/audit.d.ts +374 -0
- package/dist/core/runtime/audit.js +454 -0
- package/dist/core/runtime/claims.d.ts +115 -0
- package/dist/core/runtime/claims.js +153 -0
- package/dist/core/runtime/closure.d.ts +73 -0
- package/dist/core/runtime/closure.js +162 -0
- package/dist/core/runtime/controller.d.ts +40 -0
- package/dist/core/runtime/controller.js +121 -0
- package/dist/core/runtime/escalation.d.ts +188 -0
- package/dist/core/runtime/escalation.js +322 -0
- package/dist/core/runtime/execution-state.d.ts +43 -0
- package/dist/core/runtime/execution-state.js +81 -0
- package/dist/core/runtime/front.d.ts +95 -0
- package/dist/core/runtime/front.js +144 -0
- package/dist/core/runtime/orchestrator.d.ts +54 -0
- package/dist/core/runtime/orchestrator.js +98 -0
- package/dist/core/runtime/query.d.ts +184 -0
- package/dist/core/runtime/query.js +213 -0
- package/dist/core/runtime/report.d.ts +33 -0
- package/dist/core/runtime/report.js +108 -0
- package/dist/core/runtime/session.d.ts +156 -0
- package/dist/core/runtime/session.js +281 -0
- package/dist/core/runtime/store-ops.d.ts +22 -0
- package/dist/core/runtime/store-ops.js +26 -0
- package/dist/core/view/build-view.d.ts +36 -0
- package/dist/core/view/build-view.js +131 -0
- package/dist/core/view/decision-view.d.ts +564 -0
- package/dist/core/view/decision-view.js +103 -0
- package/dist/core/workspace/identity.d.ts +82 -0
- package/dist/core/workspace/identity.js +133 -0
- package/dist/core/workspace/index-store.d.ts +140 -0
- package/dist/core/workspace/index-store.js +125 -0
- package/dist/core/workspace/migrate.d.ts +73 -0
- package/dist/core/workspace/migrate.js +123 -0
- package/dist/core/workspace/resolve.d.ts +34 -0
- package/dist/core/workspace/resolve.js +89 -0
- package/dist/ports/adapter.d.ts +35 -0
- package/dist/ports/adapter.js +12 -0
- package/dist/ports/approval.d.ts +98 -0
- package/dist/ports/approval.js +7 -0
- package/dist/ports/change-context.d.ts +22 -0
- package/dist/ports/change-context.js +8 -0
- package/dist/ports/event-source.d.ts +42 -0
- package/dist/ports/event-source.js +6 -0
- package/dist/ports/inventory.d.ts +53 -0
- package/dist/ports/inventory.js +12 -0
- package/dist/ports/presentation.d.ts +62 -0
- package/dist/ports/presentation.js +13 -0
- package/dist/ports/renderer.d.ts +23 -0
- package/dist/ports/renderer.js +7 -0
- package/dist/ports/resource-context.d.ts +51 -0
- package/dist/ports/resource-context.js +9 -0
- package/dist/ports/scm.d.ts +40 -0
- package/dist/ports/scm.js +6 -0
- package/dist/ports/state-store.d.ts +97 -0
- package/dist/ports/state-store.js +12 -0
- package/dist/presets/balanced.json +8 -0
- package/dist/presets/conservative.json +9 -0
- package/dist/presets/lightweight.json +8 -0
- package/dist/profiles/example-team/profile.json +101 -0
- package/dist/profiles/pilot-local/profile.json +56 -0
- package/dist/schemas/profile.d.ts +631 -0
- package/dist/schemas/profile.js +287 -0
- package/package.json +55 -0
package/dist/cli/asc.js
ADDED
|
@@ -0,0 +1,2914 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ASC CLI — Local Operator Interface의 첫 Surface.
|
|
3
|
+
//
|
|
4
|
+
// 이것은 여러 소비자 중 하나일 뿐이다 (C-01 §4). MCP·IDE 확장·Web UI가 나중에 같은
|
|
5
|
+
// Operator를 부르게 되며, 그때 Core는 손대지 않는다. 그래서 여기에는 명령어 해석과
|
|
6
|
+
// 출력만 있고 판단은 한 줄도 없다.
|
|
7
|
+
//
|
|
8
|
+
// 읽기 전용이다. 결정 제출은 사람의 명시적 의사표현을 받는 별도 경로로 나간다 (B-06).
|
|
9
|
+
import { execFile, spawnSync } from 'node:child_process';
|
|
10
|
+
import { parseArgs, promisify } from 'node:util';
|
|
11
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
12
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, join, resolve } from 'node:path';
|
|
16
|
+
import { GitHubClient, discoverToken } from "../adapters/github/client.js";
|
|
17
|
+
import { GitHubChangeContext, GitHubInventory, GitHubResourceContext } from "../adapters/github/context.js";
|
|
18
|
+
import { GitHubEventSource } from "../adapters/github/event-source.js";
|
|
19
|
+
import { GitHubScm } from "../adapters/github/scm.js";
|
|
20
|
+
import { MarkdownStateStore } from "../adapters/markdown/state-store.js";
|
|
21
|
+
import { LocalIdentityBinding } from "../adapters/local/identity.js";
|
|
22
|
+
import { TextRenderer } from "../adapters/text/renderer.js";
|
|
23
|
+
import { ApprovalService } from "../core/approval/service.js";
|
|
24
|
+
import { Executor } from "../core/execution/executor.js";
|
|
25
|
+
import { GrantService } from "../core/execution/grant.js";
|
|
26
|
+
import { DecisionKind } from "../core/model/entities.js";
|
|
27
|
+
import { discoverProjectRoot, excludeFromGit, identitiesTemplate, overrideTemplate, writeIfAbsent } from "../core/attach/init.js";
|
|
28
|
+
import { locatorsOf, lookupLocator, readIndex, register, writeIndex } from "../core/workspace/index-store.js";
|
|
29
|
+
import { adoptionLine, judgeAdoption, migrate } from "../core/workspace/migrate.js";
|
|
30
|
+
import { newWorkspaceId, normalizeRemote, recoverCandidates, recoverLines } from "../core/workspace/identity.js";
|
|
31
|
+
import { resolveWorkspace, resolutionLine } from "../core/workspace/resolve.js";
|
|
32
|
+
import { assessSetup, renderSetup } from "../core/attach/setup.js";
|
|
33
|
+
import { applySetupPlan, computeSetupPlan, renderSetupPlan, } from "../core/attach/setup-plan.js";
|
|
34
|
+
import { CLAUDE_PROVIDER, CLAUDE_SCOPE, claudeBindings } from "../adapters/claude-code/binding.js";
|
|
35
|
+
import { readHeartbeat } from "../adapters/claude-code/observer.js";
|
|
36
|
+
import { workerContract, workerSettings } from "../adapters/claude-code/guard.js";
|
|
37
|
+
import { applyHostReport, assessReadiness, probe } from "../adapters/claude-code/probe.js";
|
|
38
|
+
import { defaultPaths, install, installReportLines, uninstall, verifyInstall, verifyInstalled, } from "../adapters/claude-code/install.js";
|
|
39
|
+
import { MonitorEngine } from "../core/monitor/engine.js";
|
|
40
|
+
import { CoverageLedger, renderHealth } from "../core/monitor/coverage.js";
|
|
41
|
+
import { evaluateHealth, healthAlertLines } from "../core/monitor/health-alerts.js";
|
|
42
|
+
import { Operator } from "../core/operator/proceed.js";
|
|
43
|
+
import { ProgressService } from "../core/operator/progress.js";
|
|
44
|
+
import { composeBindings, defaultAdapters } from "../composition/registry.js";
|
|
45
|
+
import { buildRuntimePorts, rolesFor } from "../composition/runtime.js";
|
|
46
|
+
import { buildEventObservation } from "../composition/observe.js";
|
|
47
|
+
import { availableProfiles, planBootstrap, renderPlan } from "../core/attach/bootstrap.js";
|
|
48
|
+
import { detectStableInstall, installStableRuntime, verifyStableInstall, } from "../core/distribution/runtime-install.js";
|
|
49
|
+
import { readRuntimeSelection, remediationAction, remediationLines, resolveRuntimeTarget, runtimeSelectionLine, selectionPath, writeRuntimeSelection, } from "../core/distribution/runtime-select.js";
|
|
50
|
+
import { preflight } from "../core/operator/preflight.js";
|
|
51
|
+
import { lookupAuthority } from "../core/policy/ownership.js";
|
|
52
|
+
import { renderProgress } from "../core/operator/render.js";
|
|
53
|
+
import { AuditLedger, decisionLines, delegationLine, executionLines, reclaimLine, validationLines, } from "../core/runtime/audit.js";
|
|
54
|
+
import { ClosureLedger } from "../core/runtime/closure.js";
|
|
55
|
+
import { Orchestrator, renderTick } from "../core/runtime/orchestrator.js";
|
|
56
|
+
import { QueryLedger } from "../core/runtime/query.js";
|
|
57
|
+
import { ObservationLedger } from "../core/monitor/observation.js";
|
|
58
|
+
import { DeliveryLedger, deliver, planDigest } from "../core/presentation/digest.js";
|
|
59
|
+
import { LocalPresentation } from "../adapters/local/presentation.js";
|
|
60
|
+
import { collectSessions, renderCollect } from "../core/runtime/controller.js";
|
|
61
|
+
import { renderFront, restoreFront } from "../core/runtime/front.js";
|
|
62
|
+
import { EscalationLedger, escalationLines } from "../core/runtime/escalation.js";
|
|
63
|
+
import { deriveExecutionState, executionLine } from "../core/runtime/execution-state.js";
|
|
64
|
+
import { buildFinalReport, renderFinalReport } from "../core/runtime/report.js";
|
|
65
|
+
import { FreezeLedger, freezeLines, judgeAction } from "../core/policy/remote-freeze.js";
|
|
66
|
+
import { SessionRuntime } from "../core/runtime/session.js";
|
|
67
|
+
import { Checkpoint, Handoff, SessionRole } from "../core/model/entities.js";
|
|
68
|
+
import { archiveLock, bootstrapGuard, buildLock, compareLock, loadLayers, resolveRuntime } from "../core/resolver/load.js";
|
|
69
|
+
import { renderAscMd, renderControllerMd } from "../core/resolver/render.js";
|
|
70
|
+
import { ProfileLock } from "../schemas/profile.js";
|
|
71
|
+
import { LocalOperator } from "../core/operator/local-operator.js";
|
|
72
|
+
import { loadIdentityMap } from "./identity-config.js";
|
|
73
|
+
const USAGE = `asc — Agent Session Control
|
|
74
|
+
|
|
75
|
+
asc proceed [--session <id>] [--goal <text>] [--json]
|
|
76
|
+
|
|
77
|
+
asc inbox list [--all] [--priority P0|P1|P2] [--json]
|
|
78
|
+
asc inbox show <REQUEST_ID> [--json]
|
|
79
|
+
asc inbox trace <REQUEST_ID> [--json] # how it got here — an exploratory trace
|
|
80
|
+
asc inbox digest [--flush] [--json] # batched view (P0 stays separate)
|
|
81
|
+
asc inbox latest [--priority P0|P1|P2] [--json]
|
|
82
|
+
asc inbox decide <REQUEST_ID> <approve|revise|defer|dismiss|queue> --as <actor>
|
|
83
|
+
[--revision <text>] [--expect <version>]
|
|
84
|
+
|
|
85
|
+
asc grant issue <REQUEST_ID> --action <key> --target <ref> --as <actor>
|
|
86
|
+
[--grant-id <id>] [--expires <iso>]
|
|
87
|
+
asc grant run <GRANT_ID> [--run-id <id>]
|
|
88
|
+
|
|
89
|
+
asc monitor scan [--backfill] [--as <controller>] # fast path
|
|
90
|
+
asc monitor reconcile [--as <controller>] # recover what was missed — re-list
|
|
91
|
+
asc monitor census [--as <controller>] # full reconcile + detect disappearances
|
|
92
|
+
asc monitor status [--json] # how far coverage has been confirmed
|
|
93
|
+
|
|
94
|
+
asc profile resolve --profile <id> [--preset <id>] [--install <path>] [--write]
|
|
95
|
+
|
|
96
|
+
asc runtime start [--interval-min <n>] [--delta-min <n>] [--reconcile-min <n>]
|
|
97
|
+
[--census-min <n>] [--digest-min <n>]
|
|
98
|
+
asc runtime tick
|
|
99
|
+
asc runtime status [--json] # which build is in use (C-14 §4)
|
|
100
|
+
asc runtime use package
|
|
101
|
+
asc runtime use development <checkout> # run a built checkout instead
|
|
102
|
+
asc front [status] [--json]
|
|
103
|
+
asc escalate open <S-ID> --predicate <p>... --question <t> --blocked <node>...
|
|
104
|
+
--evidence <ref>... [--blocked-scope <path>...] [--previous <ESC-ID> --why <t>]
|
|
105
|
+
asc escalate list
|
|
106
|
+
asc escalate resolve <ESC-ID>
|
|
107
|
+
asc freeze [status]
|
|
108
|
+
asc freeze on --reason <text> [--offline]
|
|
109
|
+
asc freeze defer --id <id> --intent <text> [--evidence <ref>]
|
|
110
|
+
asc freeze release --id <id>
|
|
111
|
+
asc thaw
|
|
112
|
+
asc workspace list
|
|
113
|
+
asc workspace migrate [--force]
|
|
114
|
+
asc init [--profile <id>] [--preset <id>] [--install <path>]
|
|
115
|
+
[--scope local|project] [--workspace <W-id>]
|
|
116
|
+
# without --profile: report what was detected, then stop
|
|
117
|
+
|
|
118
|
+
asc setup status [--json]
|
|
119
|
+
asc setup plan [--profile <id>] [--scope local|project] [--json]
|
|
120
|
+
# says what it would change — changes nothing
|
|
121
|
+
asc setup apply [--profile <id>] [--scope local|project] [--json]
|
|
122
|
+
asc setup --agent # non-interactive apply. stdout is a single JSON document
|
|
123
|
+
|
|
124
|
+
asc session issue <ID> --role <role> --goal <text> [--block <id>]
|
|
125
|
+
[--parent <S-ID>] [--issued-by <principal>]
|
|
126
|
+
asc session pause <S-ID> --position <t> --next <t> [--physical <id>]
|
|
127
|
+
[--judgment <t>] [--blocker <t>] [--risk <t>] [--evidence <ref>]
|
|
128
|
+
asc session validate <target S-ID> --validator <validator S-ID> --result PASS|FAIL [--finding <t>]
|
|
129
|
+
asc session audit <S-ID>
|
|
130
|
+
asc session report <S-ID> [--json]
|
|
131
|
+
asc session decision <S-ID> --class <c> --selected <t> --why <t>... --evidence <ref>...
|
|
132
|
+
[--alternative <t>...] [--ownership <scope>...] [--verification <t>...]
|
|
133
|
+
[--boundary <glob>...] [--exception <item>...]
|
|
134
|
+
[--criteria <text>...] [--owner <role>]
|
|
135
|
+
[--domain <decision-domain>...] [--authority <domain>=<role>...]
|
|
136
|
+
[--dependency <text>...]
|
|
137
|
+
asc session start <ID>
|
|
138
|
+
asc session pause <ID> --position <text> --next <text> [--done <task>...]
|
|
139
|
+
asc session resume <ID>
|
|
140
|
+
asc session done <ID> --verified <text> --next <text> [--done <task>...]
|
|
141
|
+
[--changed <path>...] [--unresolved <text>...]
|
|
142
|
+
asc session list
|
|
143
|
+
|
|
144
|
+
asc controller collect
|
|
145
|
+
|
|
146
|
+
asc closure list [<S-ID>]
|
|
147
|
+
asc closure confirm <S-ID> --item <id>...
|
|
148
|
+
|
|
149
|
+
asc preflight --path <p>... (--role <r> | --session <S-ID>) [--json]
|
|
150
|
+
|
|
151
|
+
asc query open <X-ID> --session <S-ID> --domain <decision-domain>
|
|
152
|
+
--question <text> [--context <text>] [--default <text>]
|
|
153
|
+
[--blocking <text>] [--expect-response DECIDE|ANSWER]
|
|
154
|
+
[--in-reply-to <X-ID>]
|
|
155
|
+
asc query answer <X-ID> --kind DECIDE|ANSWER|ESCALATE --by <role> --body <text>
|
|
156
|
+
[--to <authority>]
|
|
157
|
+
asc query list [--json]
|
|
158
|
+
|
|
159
|
+
asc progress show [<S-ID>]
|
|
160
|
+
asc progress report <S-ID> --physical <id> --phase <text>
|
|
161
|
+
[--milestone <text>...] [--next <text>] [--unresolved <text>...]
|
|
162
|
+
[--decision none|later|now] [--decision-ref <text>]
|
|
163
|
+
[--verifier none|running|pass|fail] [--verifier-detail <text>] [--terminal]
|
|
164
|
+
|
|
165
|
+
asc host claude install [--force] # --force: overwrite ASC files a person has edited
|
|
166
|
+
asc host claude uninstall|probe [--report <cap>=<bool>...]
|
|
167
|
+
asc host claude guard
|
|
168
|
+
asc host claude bind <S-ID> --physical <id> [--principal <p>] [--worker <id>] [--kind <k>] [--force]
|
|
169
|
+
asc host claude release <S-ID> --physical <id>
|
|
170
|
+
asc host claude contract <S-ID>
|
|
171
|
+
|
|
172
|
+
Options
|
|
173
|
+
--root <path> runtime directory (otherwise: registered workspace, then repo-local .asc)
|
|
174
|
+
--json machine-readable output
|
|
175
|
+
--as <actor> who is deciding. Must be mapped as an approver
|
|
176
|
+
--revision what was changed, when approving with revisions
|
|
177
|
+
--expect the version you read. Rejected if it changed since
|
|
178
|
+
--action action key to emit (e.g. github.issue_comment.create)
|
|
179
|
+
--target target reference (e.g. owner/repo#19)
|
|
180
|
+
--backfill sweep history on the first run (default: from now on)
|
|
181
|
+
--profile Project Profile id
|
|
182
|
+
--preset Operational Preset id
|
|
183
|
+
--install ASC installation path (default: where this CLI lives)
|
|
184
|
+
--write actually write the artefacts (default: preview)
|
|
185
|
+
--role planner|researcher|implementer|verifier
|
|
186
|
+
--goal the single goal of this session
|
|
187
|
+
--boundary write scope (must be narrower than the Profile's)
|
|
188
|
+
--exception SOFT DENY item allowed for this session only
|
|
189
|
+
--criteria a verifiable done-criterion (repeatable)
|
|
190
|
+
--path output path to check (preflight, repeatable)
|
|
191
|
+
--item confirmed closure item id (closure confirm, repeatable)
|
|
192
|
+
--phase one line on what is happening right now (progress report)
|
|
193
|
+
--milestone a meaningfully finished chunk (repeatable)
|
|
194
|
+
--decision does a person need to decide: none|later|now
|
|
195
|
+
--verifier independent verification state: none|running|pass|fail
|
|
196
|
+
--terminal final report — stays as the closing screen after collect
|
|
197
|
+
|
|
198
|
+
decide assumes a person is operating it, and only checks that the name given with --as
|
|
199
|
+
is registered as an approver. Approval is not permission to publish: anything reaching an
|
|
200
|
+
external system goes out through a separate Execution Grant.`;
|
|
201
|
+
/**
|
|
202
|
+
* 지금 여기가 어느 ASC runtime인가. **모든 명령이 같은 문을 지난다** (C-11 §3, B-45).
|
|
203
|
+
*
|
|
204
|
+
* 예전에는 이 판단이 네 군데에 흩어져 있었고 `--root` 가 host 명령에만 안 먹었다.
|
|
205
|
+
* 이제 한 곳이라 그런 비대칭이 생기지 않는다.
|
|
206
|
+
*/
|
|
207
|
+
async function discoverRoot(start, explicitRoot) {
|
|
208
|
+
const resolution = await resolveRoot(start, explicitRoot);
|
|
209
|
+
return resolution.kind === 'UNRESOLVED' ? null : resolution.root;
|
|
210
|
+
}
|
|
211
|
+
async function resolveRoot(start, explicitRoot) {
|
|
212
|
+
return resolveWorkspace({
|
|
213
|
+
cwd: start,
|
|
214
|
+
...(explicitRoot ? { explicitRoot } : {}),
|
|
215
|
+
index: await readIndex(ascHome()),
|
|
216
|
+
// 홈의 `~/.asc` 는 user runtime이지 프로젝트 상태가 아니다 — 그 위로 올라가지 않는다
|
|
217
|
+
stopAt: homedir(),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const execFileAsync = promisify(execFile);
|
|
221
|
+
/**
|
|
222
|
+
* 이 binding이 가리키는 주소. 자체 호스팅은 발견 단계가 이미 알아냈으므로 같은 값을 쓴다 —
|
|
223
|
+
* 여기서 다시 추측하면 두 곳이 다른 주소를 말하게 된다.
|
|
224
|
+
*/
|
|
225
|
+
function endpointOf(adapters, binding) {
|
|
226
|
+
for (const adapter of adapters) {
|
|
227
|
+
const withEndpoint = adapter;
|
|
228
|
+
if (adapter.describe().id !== binding.adapterId)
|
|
229
|
+
continue;
|
|
230
|
+
return withEndpoint.endpointFor?.(binding.resource);
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
/** 관측 기록이 사는 자리. source id가 정한다 — provider 이름을 여기 박지 않는다. */
|
|
235
|
+
const monitorScope = (sourceId) => `monitor:${sourceId}`;
|
|
236
|
+
/**
|
|
237
|
+
* 묶음 전달. 사람이 `asc inbox digest` 로 부르든 상시 Runtime이 부르든 같은 함수다 —
|
|
238
|
+
* 경로마다 다르게 묶으면 "언제 보낸 것이냐"에 따라 내용이 달라진다 (C-12 불변식 ②).
|
|
239
|
+
*/
|
|
240
|
+
/** 감시 상태 임계값. Core 상수가 아니다 (C-12 불변식 ⑭) — 여기서 정해 넣는다. */
|
|
241
|
+
const HEALTH_THRESHOLDS = { hotPathMs: 6 * 60 * 60_000, reconcileMs: 24 * 60 * 60_000, censusMs: 7 * 24 * 60 * 60_000 };
|
|
242
|
+
async function deliverDigest(store, flush) {
|
|
243
|
+
const channel = new LocalPresentation();
|
|
244
|
+
const ledger = new DeliveryLedger(store.scope('presentation'));
|
|
245
|
+
const scope = await activeMonitorScope(store);
|
|
246
|
+
const shadow = await new ObservationLedger(store.scope(scope)).shadowed();
|
|
247
|
+
const operator = new LocalOperator({ store });
|
|
248
|
+
const at = new Date().toISOString();
|
|
249
|
+
// 목록이 조용한 이유가 조용해서인지 못 봐서인지 함께 판정한다 (C-12 §3)
|
|
250
|
+
const health = evaluateHealth(await new CoverageLedger(store.scope(scope)).health(), at, HEALTH_THRESHOLDS);
|
|
251
|
+
const plan = planDigest({
|
|
252
|
+
at,
|
|
253
|
+
pending: await operator.list({}),
|
|
254
|
+
shadowCount: shadow.length,
|
|
255
|
+
health,
|
|
256
|
+
// --flush 면 이미 보낸 것도 다시 묶는다. 기본은 안 보낸 것만.
|
|
257
|
+
...(flush ? {} : { delivered: await ledger.delivered(channel.id) }),
|
|
258
|
+
});
|
|
259
|
+
// 채널 Port는 batch만 받는다 — 감시 경고는 계획을 만든 쪽이 안다. 여기서 함께 보인다.
|
|
260
|
+
for (const line of healthAlertLines(plan.health))
|
|
261
|
+
console.error(line);
|
|
262
|
+
const report = await deliver(plan, channel, ledger);
|
|
263
|
+
for (const line of report.degraded)
|
|
264
|
+
console.error(` (${line})`);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
const ACTIVE_SOURCE_KEY = 'active-source';
|
|
268
|
+
/**
|
|
269
|
+
* 지금 붙어 있는 감시 통로가 무엇인지 남긴다.
|
|
270
|
+
*
|
|
271
|
+
* digest처럼 조립을 하지 않는 명령도 같은 자리를 읽어야 하는데, 그걸 알아내려고 discovery와
|
|
272
|
+
* probe를 다시 돌리면 화면 하나 그리자고 외부를 친다. 그래서 monitor가 돌 때 적어 둔다.
|
|
273
|
+
*/
|
|
274
|
+
async function rememberMonitorSource(store, sourceId) {
|
|
275
|
+
await store.scope('monitor').set(ACTIVE_SOURCE_KEY, sourceId);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* 마지막으로 붙었던 통로의 scope. 기록이 없으면 예전 이름으로 본다 —
|
|
279
|
+
* 이전 설치의 shadow 기록이 이름이 바뀌었다는 이유로 사라진 것처럼 보이면 안 된다.
|
|
280
|
+
*/
|
|
281
|
+
async function activeMonitorScope(store) {
|
|
282
|
+
const sourceId = await store.scope('monitor').get(ACTIVE_SOURCE_KEY);
|
|
283
|
+
return monitorScope(sourceId ?? 'github-poll');
|
|
284
|
+
}
|
|
285
|
+
/** user-owned ASC home. */
|
|
286
|
+
const ascHome = () => process.env.ASC_HOME ?? join(homedir(), '.asc');
|
|
287
|
+
const DECISION_ERROR = {
|
|
288
|
+
NOT_FOUND: '요청을 찾지 못했다.',
|
|
289
|
+
FORBIDDEN_ACTOR: '승인 권한자가 아니다. .asc/identities.json 에 `"이름": ["local:계정"]` 형태로 매핑을 추가하라 ' +
|
|
290
|
+
'(현재 상태는 `asc setup status`).',
|
|
291
|
+
NOT_ALLOWED_DECISION: '이 요청이 허용하지 않는 결정이다.',
|
|
292
|
+
EXPIRED: '만료된 요청이다.',
|
|
293
|
+
ALREADY_DECIDED: '이미 결정된 요청이다.',
|
|
294
|
+
STALE: '읽은 뒤 요청이 바뀌었다. 다시 확인하고 결정하라.',
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* CLI 한 번의 실행. **다른 진입도 이 함수를 부른다** (C-14 불변식 ①).
|
|
298
|
+
*
|
|
299
|
+
* bootstrap 패키지는 아직 아무것도 설치되지 않은 machine에서 이것을 그대로 부른다 —
|
|
300
|
+
* 진입이 둘이어도 판단은 하나다. 그래서 export이고, 그래서 아래 자동 실행은 이 파일이
|
|
301
|
+
* 진짜 진입점일 때만 돈다.
|
|
302
|
+
*/
|
|
303
|
+
export async function runAscCommand(argv, entry = 'runtime') {
|
|
304
|
+
const { values, positionals } = parseArgs({
|
|
305
|
+
args: argv,
|
|
306
|
+
allowPositionals: true,
|
|
307
|
+
options: {
|
|
308
|
+
root: { type: 'string' },
|
|
309
|
+
parent: { type: 'string' },
|
|
310
|
+
'issued-by': { type: 'string' },
|
|
311
|
+
principal: { type: 'string' },
|
|
312
|
+
judgment: { type: 'string' },
|
|
313
|
+
scope: { type: 'string' },
|
|
314
|
+
reason: { type: 'string' },
|
|
315
|
+
predicate: { type: 'string', multiple: true },
|
|
316
|
+
blocked: { type: 'string', multiple: true },
|
|
317
|
+
'blocked-scope': { type: 'string', multiple: true },
|
|
318
|
+
affected: { type: 'string', multiple: true },
|
|
319
|
+
previous: { type: 'string' },
|
|
320
|
+
selected: { type: 'string' },
|
|
321
|
+
class: { type: 'string' },
|
|
322
|
+
alternative: { type: 'string', multiple: true },
|
|
323
|
+
ownership: { type: 'string', multiple: true },
|
|
324
|
+
verification: { type: 'string', multiple: true },
|
|
325
|
+
why: { type: 'string', multiple: true },
|
|
326
|
+
offline: { type: 'boolean', default: false },
|
|
327
|
+
id: { type: 'string' },
|
|
328
|
+
intent: { type: 'string' },
|
|
329
|
+
grant: { type: 'string' },
|
|
330
|
+
'interval-min': { type: 'string' },
|
|
331
|
+
'delta-min': { type: 'string' },
|
|
332
|
+
'reconcile-min': { type: 'string' },
|
|
333
|
+
'census-min': { type: 'string' },
|
|
334
|
+
'digest-min': { type: 'string' },
|
|
335
|
+
workspace: { type: 'string' },
|
|
336
|
+
validator: { type: 'string' },
|
|
337
|
+
result: { type: 'string' },
|
|
338
|
+
finding: { type: 'string', multiple: true },
|
|
339
|
+
blocker: { type: 'string', multiple: true },
|
|
340
|
+
risk: { type: 'string', multiple: true },
|
|
341
|
+
evidence: { type: 'string', multiple: true },
|
|
342
|
+
json: { type: 'boolean', default: false },
|
|
343
|
+
all: { type: 'boolean', default: false },
|
|
344
|
+
priority: { type: 'string' },
|
|
345
|
+
as: { type: 'string' },
|
|
346
|
+
action: { type: 'string' },
|
|
347
|
+
target: { type: 'string' },
|
|
348
|
+
'grant-id': { type: 'string' },
|
|
349
|
+
profile: { type: 'string' },
|
|
350
|
+
preset: { type: 'string' },
|
|
351
|
+
install: { type: 'string' },
|
|
352
|
+
write: { type: 'boolean', default: false },
|
|
353
|
+
backfill: { type: 'boolean', default: false },
|
|
354
|
+
role: { type: 'string' },
|
|
355
|
+
goal: { type: 'string' },
|
|
356
|
+
block: { type: 'string' },
|
|
357
|
+
boundary: { type: 'string', multiple: true },
|
|
358
|
+
exception: { type: 'string', multiple: true },
|
|
359
|
+
criteria: { type: 'string', multiple: true },
|
|
360
|
+
owner: { type: 'string' },
|
|
361
|
+
domain: { type: 'string', multiple: true },
|
|
362
|
+
authority: { type: 'string', multiple: true },
|
|
363
|
+
dependency: { type: 'string', multiple: true },
|
|
364
|
+
question: { type: 'string' },
|
|
365
|
+
context: { type: 'string' },
|
|
366
|
+
default: { type: 'string' },
|
|
367
|
+
blocking: { type: 'string' },
|
|
368
|
+
'expect-response': { type: 'string' },
|
|
369
|
+
'in-reply-to': { type: 'string' },
|
|
370
|
+
by: { type: 'string' },
|
|
371
|
+
body: { type: 'string' },
|
|
372
|
+
to: { type: 'string' },
|
|
373
|
+
session: { type: 'string' },
|
|
374
|
+
position: { type: 'string' },
|
|
375
|
+
next: { type: 'string' },
|
|
376
|
+
done: { type: 'string', multiple: true },
|
|
377
|
+
changed: { type: 'string', multiple: true },
|
|
378
|
+
verified: { type: 'string' },
|
|
379
|
+
unresolved: { type: 'string', multiple: true },
|
|
380
|
+
'run-id': { type: 'string' },
|
|
381
|
+
physical: { type: 'string' },
|
|
382
|
+
worker: { type: 'string' },
|
|
383
|
+
kind: { type: 'string' },
|
|
384
|
+
force: { type: 'boolean', default: false },
|
|
385
|
+
agent: { type: 'boolean', default: false },
|
|
386
|
+
report: { type: 'string', multiple: true },
|
|
387
|
+
flush: { type: 'boolean', default: false },
|
|
388
|
+
path: { type: 'string', multiple: true },
|
|
389
|
+
item: { type: 'string', multiple: true },
|
|
390
|
+
phase: { type: 'string' },
|
|
391
|
+
milestone: { type: 'string', multiple: true },
|
|
392
|
+
decision: { type: 'string' },
|
|
393
|
+
'decision-ref': { type: 'string' },
|
|
394
|
+
verifier: { type: 'string' },
|
|
395
|
+
'verifier-detail': { type: 'string' },
|
|
396
|
+
terminal: { type: 'boolean', default: false },
|
|
397
|
+
expires: { type: 'string' },
|
|
398
|
+
revision: { type: 'string' },
|
|
399
|
+
expect: { type: 'string' },
|
|
400
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
const [group, command, target, extra] = positionals;
|
|
404
|
+
if (values.help || group === undefined) {
|
|
405
|
+
console.log(USAGE);
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
// 선택된 build로 넘길 것이 있으면 여기서 넘긴다. **선택 자체를 다루는 명령은 넘기지
|
|
409
|
+
// 않는다** — 잘못 가리키는 선택을 고치거나 들여다보는 명령이 그 선택 때문에 못 돌면
|
|
410
|
+
// 사람이 갇힌다.
|
|
411
|
+
if (!(group === 'runtime' && (command === 'use' || command === 'status'))) {
|
|
412
|
+
const redispatched = await redispatchIfNeeded(argv);
|
|
413
|
+
if (redispatched !== null)
|
|
414
|
+
return redispatched;
|
|
415
|
+
}
|
|
416
|
+
if (group === 'init')
|
|
417
|
+
return runInit(values);
|
|
418
|
+
if (group === 'workspace')
|
|
419
|
+
return runWorkspace(command, values);
|
|
420
|
+
// 어느 build를 쓸지는 **프로젝트와 무관하다** — 붙기 전에도 답해야 한다 (C-14 §5).
|
|
421
|
+
if (group === 'runtime' && (command === 'use' || command === 'status')) {
|
|
422
|
+
return runRuntimeSelect(command, positionals[2], positionals[3], values);
|
|
423
|
+
}
|
|
424
|
+
if (group === 'host')
|
|
425
|
+
return runHost(command, positionals[2], positionals[3], values);
|
|
426
|
+
// setup은 **붙기 전에도** 답을 줘야 한다. 아래 discoverRoot 실패는 exit 2로 끊는데,
|
|
427
|
+
// 그러면 "아직 안 붙었다"를 확인하려고 부른 명령이 안 붙었다는 이유로 죽는다.
|
|
428
|
+
if (group === 'setup')
|
|
429
|
+
return runSetup(command, values, entry);
|
|
430
|
+
if (!['inbox', 'grant', 'monitor', 'runtime', 'front', 'freeze', 'thaw', 'escalate', 'profile', 'session', 'controller', 'proceed', 'progress', 'preflight', 'closure', 'query'].includes(group)) {
|
|
431
|
+
console.error(`Unknown command: ${group}\n\n${USAGE}`);
|
|
432
|
+
return 2;
|
|
433
|
+
}
|
|
434
|
+
const root = await discoverRoot(process.cwd(), values.root);
|
|
435
|
+
if (!root) {
|
|
436
|
+
console.error('No attached ASC runtime found. Attach with `asc init`, or point at one with --root.');
|
|
437
|
+
return 2;
|
|
438
|
+
}
|
|
439
|
+
const store = new MarkdownStateStore(root);
|
|
440
|
+
const operator = new LocalOperator({ store });
|
|
441
|
+
const renderer = new TextRenderer();
|
|
442
|
+
const priority = values.priority;
|
|
443
|
+
if (group === 'profile')
|
|
444
|
+
return runProfile(command, values, root);
|
|
445
|
+
// attach된 프로젝트라면 Run을 시작하기 전에 지금 설정이 lock과 같은지 본다 (OM §4.9)
|
|
446
|
+
const guard = await checkBootstrap(root);
|
|
447
|
+
if (guard.code !== 0)
|
|
448
|
+
return guard.code;
|
|
449
|
+
if (group === 'proceed')
|
|
450
|
+
return runProceed(values, store, root, guard.runtime);
|
|
451
|
+
if (group === 'session')
|
|
452
|
+
return runSession(command, target, values, store, guard.runtime);
|
|
453
|
+
if (group === 'controller')
|
|
454
|
+
return runController(command, values, store, guard.runtime);
|
|
455
|
+
if (group === 'closure')
|
|
456
|
+
return runClosure(command, target, values, store);
|
|
457
|
+
if (group === 'query')
|
|
458
|
+
return runQuery(command, target, values, store, guard.runtime);
|
|
459
|
+
if (group === 'progress')
|
|
460
|
+
return runProgress(command, target, values, store);
|
|
461
|
+
if (group === 'preflight')
|
|
462
|
+
return runPreflight(values, store, guard.runtime);
|
|
463
|
+
if (group === 'grant')
|
|
464
|
+
return runGrant(command, target, values, store, root);
|
|
465
|
+
if (group === 'monitor')
|
|
466
|
+
return runMonitor(command, values, store, renderer, guard.runtime);
|
|
467
|
+
if (group === 'runtime')
|
|
468
|
+
return runRuntime(command, values, store, renderer, guard.runtime);
|
|
469
|
+
// 새 대화가 붙었을 때 지금 상태를 되찾는다 (C-12 §4). 읽기만 한다.
|
|
470
|
+
if (group === 'front')
|
|
471
|
+
return runFront(command, values, store, root);
|
|
472
|
+
// 사람에게 올릴 자격이 있는가 (C-13). 자격 없으면 request가 만들어지지 않는다.
|
|
473
|
+
if (group === 'escalate')
|
|
474
|
+
return runEscalate(command, target, values, store, guard.runtime);
|
|
475
|
+
// 원격을 얼린다·녹인다. 로컬 작업은 얼리지 않는다 (지시 §27).
|
|
476
|
+
if (group === 'freeze' || group === 'thaw')
|
|
477
|
+
return runFreeze(group, command, values, store);
|
|
478
|
+
switch (command) {
|
|
479
|
+
case 'list': {
|
|
480
|
+
const items = await operator.list({ all: values.all, ...(priority ? { priority } : {}) });
|
|
481
|
+
console.log(values.json ? JSON.stringify(items, null, 2) : renderer.renderList(items).text);
|
|
482
|
+
return 0;
|
|
483
|
+
}
|
|
484
|
+
case 'show': {
|
|
485
|
+
if (!target) {
|
|
486
|
+
console.error('A request id is required: asc inbox show REQ-0042');
|
|
487
|
+
return 2;
|
|
488
|
+
}
|
|
489
|
+
const outcome = await operator.get(target);
|
|
490
|
+
if (!outcome.ok) {
|
|
491
|
+
console.error(`${target} was not found.`);
|
|
492
|
+
return 1;
|
|
493
|
+
}
|
|
494
|
+
console.log(values.json ? JSON.stringify(outcome.view, null, 2) : renderer.renderDecision(outcome.view, 'full').text);
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
// 감지와 방해를 나눈다 (C-08). 여기서 새 request가 생기지 않는다 — 같은 요청의
|
|
498
|
+
// 또 하나의 표현일 뿐이다.
|
|
499
|
+
case 'digest': {
|
|
500
|
+
if (values.json) {
|
|
501
|
+
const channel = new LocalPresentation();
|
|
502
|
+
const ledger = new DeliveryLedger(store.scope('presentation'));
|
|
503
|
+
const shadow = await new ObservationLedger(store.scope(await activeMonitorScope(store))).shadowed();
|
|
504
|
+
console.log(JSON.stringify(planDigest({
|
|
505
|
+
at: new Date().toISOString(),
|
|
506
|
+
pending: await operator.list({}),
|
|
507
|
+
shadowCount: shadow.length,
|
|
508
|
+
...(values.flush ? {} : { delivered: await ledger.delivered(channel.id) }),
|
|
509
|
+
}), null, 2));
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
return deliverDigest(store, Boolean(values.flush));
|
|
513
|
+
}
|
|
514
|
+
// 탐색 수준의 가장 깊은 단계 (C-05 §3.2). 전역 mode가 아니라 이 호출 하나의 예산이다.
|
|
515
|
+
case 'trace': {
|
|
516
|
+
if (!target) {
|
|
517
|
+
console.error('A request id is required: asc inbox trace REQ-0042');
|
|
518
|
+
return 2;
|
|
519
|
+
}
|
|
520
|
+
const entries = await operator.trace(target);
|
|
521
|
+
if (values.json) {
|
|
522
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
523
|
+
return 0;
|
|
524
|
+
}
|
|
525
|
+
if (entries.length === 0) {
|
|
526
|
+
console.log(`${target} has no history.`);
|
|
527
|
+
return 0;
|
|
528
|
+
}
|
|
529
|
+
console.log(`How ${target} came to be in this state:`);
|
|
530
|
+
for (const entry of entries)
|
|
531
|
+
console.log(` ${entry.at} ${entry.actor} ${entry.kind} ${entry.detail}`);
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
case 'latest': {
|
|
535
|
+
const outcome = await operator.resolveLatest({ ...(priority ? { priority } : {}) });
|
|
536
|
+
if (outcome.kind === 'none') {
|
|
537
|
+
console.log('No pending requests');
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
if (outcome.kind === 'ambiguous') {
|
|
541
|
+
// 하나를 골라주지 않는다 — 잘못 고른 요청을 승인 화면까지 끌고 가는 것보다 한 번 더 묻는 게 싸다
|
|
542
|
+
console.log(`There are ${outcome.candidates.length} pending requests. Name the one you mean.\n`);
|
|
543
|
+
console.log(renderer.renderList(outcome.candidates).text);
|
|
544
|
+
return values.json ? 0 : 1;
|
|
545
|
+
}
|
|
546
|
+
console.log(values.json ? JSON.stringify(outcome.view, null, 2) : renderer.renderDecision(outcome.view, 'full').text);
|
|
547
|
+
return 0;
|
|
548
|
+
}
|
|
549
|
+
case 'decide': {
|
|
550
|
+
if (!target || !extra) {
|
|
551
|
+
console.error('Usage: asc inbox decide REQ-0042 approve --as <actor>');
|
|
552
|
+
return 2;
|
|
553
|
+
}
|
|
554
|
+
const kind = DecisionKind.safeParse(extra);
|
|
555
|
+
if (!kind.success) {
|
|
556
|
+
console.error(`Unknown decision: ${extra} (approve|revise|defer|dismiss|queue)`);
|
|
557
|
+
return 2;
|
|
558
|
+
}
|
|
559
|
+
// 결정한 사람의 이름을 직접 받는다. 기본값을 두면 기록에 누가 정했는지가 흐려진다.
|
|
560
|
+
if (!values.as) {
|
|
561
|
+
console.error('--as <actor> is required. Who decided is part of the record.');
|
|
562
|
+
return 2;
|
|
563
|
+
}
|
|
564
|
+
const current = await operator.get(target);
|
|
565
|
+
if (!current.ok) {
|
|
566
|
+
console.error(`${target} was not found.`);
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
// --expect 를 주지 않으면 방금 읽은 version을 쓴다. 그 사이 다른 채널이 결정했다면 거절된다.
|
|
570
|
+
const expectedVersion = values.expect ? Number(values.expect) : current.view.version;
|
|
571
|
+
const approval = new ApprovalService({ store, identity: new LocalIdentityBinding(await loadIdentityMap(root)) });
|
|
572
|
+
const outcome = await approval.submit({
|
|
573
|
+
requestId: target,
|
|
574
|
+
expectedVersion,
|
|
575
|
+
kind: kind.data,
|
|
576
|
+
actor: values.as,
|
|
577
|
+
channel: 'local',
|
|
578
|
+
...(values.revision !== undefined ? { revision: values.revision } : {}),
|
|
579
|
+
decidedAt: new Date().toISOString(),
|
|
580
|
+
});
|
|
581
|
+
if (outcome.ok) {
|
|
582
|
+
console.log(renderer.renderDecision(outcome.view, 'summary').text);
|
|
583
|
+
console.log('\nApproval is not permission to publish — anything external goes out through an Execution Grant.');
|
|
584
|
+
return 0;
|
|
585
|
+
}
|
|
586
|
+
console.error(DECISION_ERROR[outcome.reason]);
|
|
587
|
+
if ('view' in outcome)
|
|
588
|
+
console.error('\n' + renderer.renderDecision(outcome.view, 'summary').text);
|
|
589
|
+
return 1;
|
|
590
|
+
}
|
|
591
|
+
default:
|
|
592
|
+
console.error(`Unknown inbox command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
593
|
+
return 2;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* 프로젝트에 Runtime을 붙인다.
|
|
598
|
+
*
|
|
599
|
+
* **기본은 local scope다** (C-11 §0·§2). 채택하지 않은 저장소에는 아무것도 만들지 않고
|
|
600
|
+
* runtime을 사용자 소유 공간(`ASC_HOME`, 기본 `~/.asc`)에 둔다. 저장소 안에 두는 것은
|
|
601
|
+
* 팀이 그렇게 하기로 정했을 때뿐이며, 그 결정은 `--scope project` 로만 표현된다 —
|
|
602
|
+
* 자동 발견의 결과로 승격되지 않는다 (C-11 불변식 ⑤).
|
|
603
|
+
*/
|
|
604
|
+
/**
|
|
605
|
+
* user-owned runtime을 만들고 이 checkout을 역색인에 등록한다.
|
|
606
|
+
*
|
|
607
|
+
* workspace id는 새로 만들되, 이미 알아볼 수 있는 후보가 있으면 **고르지 않고 알린다** —
|
|
608
|
+
* alias 일치는 recover candidate이지 동일성 증명이 아니다 (C-11 불변식 ③).
|
|
609
|
+
*/
|
|
610
|
+
async function attachLocalWorkspace(projectRoot, git, declaredWorkspace) {
|
|
611
|
+
const home = ascHome();
|
|
612
|
+
const index = await readIndex(home);
|
|
613
|
+
const existing = lookupLocator(index, projectRoot);
|
|
614
|
+
if (existing && !declaredWorkspace) {
|
|
615
|
+
console.log(`Already registered workspace: ${existing.workspaceId}`);
|
|
616
|
+
return existing.root;
|
|
617
|
+
}
|
|
618
|
+
const remotes = git ? await gitRemotes(projectRoot) : [];
|
|
619
|
+
const aliases = remotes.map(normalizeRemote).filter((alias) => alias !== null);
|
|
620
|
+
// 사람이 "이건 그 프로젝트다"라고 말한 경우 — 이어붙인다. 추론이 아니라 선언이다.
|
|
621
|
+
if (declaredWorkspace) {
|
|
622
|
+
const known = index.workspaces[declaredWorkspace];
|
|
623
|
+
if (!known) {
|
|
624
|
+
console.error(`${declaredWorkspace} is unknown — it is not a registered workspace.`);
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
const root = join(home, 'workspaces', known.workspaceId);
|
|
628
|
+
await writeIndex(home, register(index, {
|
|
629
|
+
workspaceId: known.workspaceId,
|
|
630
|
+
root,
|
|
631
|
+
locator: { path: projectRoot, platform: process.platform, observedAt: new Date().toISOString() },
|
|
632
|
+
aliases,
|
|
633
|
+
now: new Date().toISOString(),
|
|
634
|
+
}));
|
|
635
|
+
console.log(`Registered this location under workspace ${known.workspaceId} — runtime lives at ${root}`);
|
|
636
|
+
return root;
|
|
637
|
+
}
|
|
638
|
+
const hits = recoverCandidates(Object.values(index.workspaces), remotes);
|
|
639
|
+
if (hits.length > 0) {
|
|
640
|
+
// 붙일지는 사람이 정한다. 여기서 이어붙이면 남의 workspace를 조용히 가져올 수 있다.
|
|
641
|
+
for (const line of recoverLines(hits))
|
|
642
|
+
console.log(line);
|
|
643
|
+
console.log(`같은 프로젝트라면: asc init --profile <id> --workspace ${hits[0].workspace.workspaceId}` +
|
|
644
|
+
(hits.length > 1 ? ' (후보 중 하나를 골라라)' : ''));
|
|
645
|
+
}
|
|
646
|
+
const workspaceId = newWorkspaceId();
|
|
647
|
+
const root = join(home, 'workspaces', workspaceId);
|
|
648
|
+
await writeIndex(home, register(index, {
|
|
649
|
+
workspaceId,
|
|
650
|
+
root,
|
|
651
|
+
locator: { path: projectRoot, platform: process.platform, observedAt: new Date().toISOString() },
|
|
652
|
+
aliases,
|
|
653
|
+
adoptionScope: 'local',
|
|
654
|
+
now: new Date().toISOString(),
|
|
655
|
+
}));
|
|
656
|
+
console.log(`workspace ${workspaceId} — runtime lives at ${root} (nothing is created in the repository)`);
|
|
657
|
+
if (aliases.length > 0)
|
|
658
|
+
console.log(`Recognisable as: ${aliases.join(', ')}`);
|
|
659
|
+
return root;
|
|
660
|
+
}
|
|
661
|
+
/** 모든 remote를 evidence로 모은다. origin을 primary로 단정하지 않는다 (C-11 불변식 ④). */
|
|
662
|
+
async function gitRemotes(projectRoot) {
|
|
663
|
+
try {
|
|
664
|
+
const { stdout } = await execFileAsync('git', ['-C', projectRoot, 'remote', '-v']);
|
|
665
|
+
const urls = stdout
|
|
666
|
+
.split(/\r?\n/)
|
|
667
|
+
.map((line) => line.split(/\s+/)[1])
|
|
668
|
+
.filter((url) => Boolean(url));
|
|
669
|
+
return [...new Set(urls)];
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
return [];
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* 저장소 안에 있던 `.asc/` 를 사용자 소유 공간으로 옮긴다 (C-11 §6).
|
|
677
|
+
*
|
|
678
|
+
* 판정을 먼저 하고, 모르면 옮기지 않는다. 원본은 지우지 않는다 — 확인은 사람이 한다.
|
|
679
|
+
*/
|
|
680
|
+
async function runWorkspace(command, values) {
|
|
681
|
+
if (command !== 'migrate' && command !== 'list') {
|
|
682
|
+
console.error(`Unknown workspace command: ${command ?? '(none)'}
|
|
683
|
+
|
|
684
|
+
${USAGE}`);
|
|
685
|
+
return 2;
|
|
686
|
+
}
|
|
687
|
+
const home = ascHome();
|
|
688
|
+
const index = await readIndex(home);
|
|
689
|
+
if (command === 'list') {
|
|
690
|
+
const workspaces = Object.values(index.workspaces);
|
|
691
|
+
if (workspaces.length === 0) {
|
|
692
|
+
console.log('No registered workspaces');
|
|
693
|
+
return 0;
|
|
694
|
+
}
|
|
695
|
+
for (const workspace of workspaces) {
|
|
696
|
+
console.log(`${workspace.workspaceId} [${workspace.adoptionScope}] ${workspace.aliases.join(', ') || '(no alias)'}`);
|
|
697
|
+
for (const locator of locatorsOf(index, workspace.workspaceId)) {
|
|
698
|
+
console.log(` ${locator.locator}${locator.kind ? ` (${locator.kind})` : ''}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return 0;
|
|
702
|
+
}
|
|
703
|
+
const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
|
|
704
|
+
const legacy = join(projectRoot, '.asc');
|
|
705
|
+
if (!(await pathExists(legacy))) {
|
|
706
|
+
console.log(`No .asc inside ${projectRoot} — nothing to move.`);
|
|
707
|
+
return 0;
|
|
708
|
+
}
|
|
709
|
+
const adoption = judgeAdoption({
|
|
710
|
+
projectRoot,
|
|
711
|
+
trackedAscPaths: git ? await trackedUnder(projectRoot, '.asc') : [],
|
|
712
|
+
excludeContent: await readFile(join(projectRoot, '.git', 'info', 'exclude'), 'utf8').catch(() => ''),
|
|
713
|
+
gitignoreContent: await readFile(join(projectRoot, '.gitignore'), 'utf8').catch(() => ''),
|
|
714
|
+
});
|
|
715
|
+
console.log(adoptionLine(adoption));
|
|
716
|
+
const existing = lookupLocator(index, projectRoot);
|
|
717
|
+
const workspaceId = existing?.workspaceId ?? newWorkspaceId();
|
|
718
|
+
const target = existing?.root ?? join(home, 'workspaces', workspaceId);
|
|
719
|
+
const outcome = await migrate({ from: legacy, to: target, adoption, force: Boolean(values.force) });
|
|
720
|
+
if (!outcome.ok) {
|
|
721
|
+
console.error(outcome.detail);
|
|
722
|
+
return outcome.reason === 'PROJECT_ADOPTED' ? 0 : 1;
|
|
723
|
+
}
|
|
724
|
+
const remotes = git ? await gitRemotes(projectRoot) : [];
|
|
725
|
+
await writeIndex(home, register(index, {
|
|
726
|
+
workspaceId,
|
|
727
|
+
root: target,
|
|
728
|
+
locator: { path: projectRoot, platform: process.platform, observedAt: new Date().toISOString() },
|
|
729
|
+
aliases: remotes.map(normalizeRemote).filter((alias) => alias !== null),
|
|
730
|
+
now: new Date().toISOString(),
|
|
731
|
+
}));
|
|
732
|
+
console.log(`Copied and verified ${outcome.plan.entries} entries into ${target} (workspace ${workspaceId}).`);
|
|
733
|
+
console.log('The original was left in place — check it, then remove it yourself:');
|
|
734
|
+
console.log(` rm -rf ${legacy}`);
|
|
735
|
+
return 0;
|
|
736
|
+
}
|
|
737
|
+
/** Git이 추적하는 경로 중 이 접두어 아래인 것. 하나라도 있으면 팀의 것이다. */
|
|
738
|
+
async function trackedUnder(projectRoot, prefix) {
|
|
739
|
+
try {
|
|
740
|
+
const { stdout } = await execFileAsync('git', ['-C', projectRoot, 'ls-files', prefix]);
|
|
741
|
+
return stdout.split(/\r?\n/).filter(Boolean);
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
return [];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async function pathExists(path) {
|
|
748
|
+
try {
|
|
749
|
+
await readFile(path).catch(async () => {
|
|
750
|
+
const { readdir } = await import('node:fs/promises');
|
|
751
|
+
await readdir(path);
|
|
752
|
+
});
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async function runInit(values) {
|
|
760
|
+
// Profile을 지정하지 않았다고 죽지 않는다 (C-06 §4.3). 무엇이 있고 무엇이 정해지지
|
|
761
|
+
// 않았는지 보여 주고 멈춘다 — 후보가 하나뿐이어도 대신 고르지 않는다.
|
|
762
|
+
if (!values.profile) {
|
|
763
|
+
// 등록된 adapter가 스스로 후보를 찾고 실측한다 — provider 목록을 여기서 순회하지
|
|
764
|
+
// 않는다 (C-09 §7). adapter가 없으면 그 갈래는 애초에 없다.
|
|
765
|
+
const { root: projectRoot } = await discoverProjectRoot(process.cwd());
|
|
766
|
+
const bindings = await composeBindings({ context: { projectRoot, env: process.env } });
|
|
767
|
+
// 붙었는지는 **모든 명령이 지나는 같은 문**으로 묻는다 (C-11 §3). 저장소 안의 `.asc`
|
|
768
|
+
// 만 보면 local scope로 붙은 workspace를 못 본다 — 기본 경로인데 "아직 안 붙었다"고
|
|
769
|
+
// 답하게 된다.
|
|
770
|
+
const attachedRoot = await discoverRoot(process.cwd(), values.root);
|
|
771
|
+
// 붙어 있다고 정책이 다 정해진 것이 아니다. Profile이 실제로 무엇을 선언했는지
|
|
772
|
+
// 여기서 읽어 넘기고, 남은 것을 계획이 묻는다.
|
|
773
|
+
const plan = await planBootstrap({
|
|
774
|
+
cwd: process.cwd(),
|
|
775
|
+
installRoot: installRoot(),
|
|
776
|
+
...(attachedRoot ? { ascRoot: attachedRoot } : {}),
|
|
777
|
+
hosts: [{ id: 'claude', installed: await verifyInstalled(defaultPaths()) }],
|
|
778
|
+
bindings,
|
|
779
|
+
declaredPolicies: declaredPolicies(attachedRoot ? await attachedRuntime(attachedRoot) : undefined),
|
|
780
|
+
});
|
|
781
|
+
console.log(renderPlan(plan));
|
|
782
|
+
return 0;
|
|
783
|
+
}
|
|
784
|
+
const scope = values.scope === 'project' ? 'project' : 'local';
|
|
785
|
+
if (values.scope !== undefined && values.scope !== 'project' && values.scope !== 'local') {
|
|
786
|
+
console.error(`--scope must be local or project (received: ${values.scope})`);
|
|
787
|
+
return 2;
|
|
788
|
+
}
|
|
789
|
+
const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
|
|
790
|
+
console.log(`Project: ${projectRoot}${git ? '' : ' (not a git repository)'}`);
|
|
791
|
+
const ascRoot = scope === 'project'
|
|
792
|
+
? join(projectRoot, '.asc')
|
|
793
|
+
: await attachLocalWorkspace(projectRoot, git, values.workspace);
|
|
794
|
+
if (!ascRoot)
|
|
795
|
+
return 2;
|
|
796
|
+
await MarkdownStateStore.open(ascRoot);
|
|
797
|
+
if (scope === 'project') {
|
|
798
|
+
// 팀이 저장소에 두기로 한 경우에만 추적 제외를 손댄다.
|
|
799
|
+
// local scope에서는 저장소 파일을 한 바이트도 건드리지 않는다 (C-11 §5).
|
|
800
|
+
const excluded = await excludeFromGit(projectRoot);
|
|
801
|
+
console.log(excluded === 'added'
|
|
802
|
+
? '.git/info/exclude 에 .asc/ 추가'
|
|
803
|
+
: excluded === 'already'
|
|
804
|
+
? '.asc/ 는 이미 Git 추적에서 빠져 있다'
|
|
805
|
+
: 'git 저장소가 아니라 추적 제외는 건너뛴다');
|
|
806
|
+
}
|
|
807
|
+
if (await writeIfAbsent(join(ascRoot, 'override.json'), overrideTemplate()))
|
|
808
|
+
console.log('created the override.json template');
|
|
809
|
+
if (await writeIfAbsent(join(ascRoot, 'identities.json'), identitiesTemplate())) {
|
|
810
|
+
console.log('created the identities.json template — while it is empty, no approval passes');
|
|
811
|
+
}
|
|
812
|
+
const resolved = await runProfile('resolve', { ...values, write: true }, ascRoot);
|
|
813
|
+
if (resolved !== 0)
|
|
814
|
+
return resolved;
|
|
815
|
+
// 무엇이 열려 있고 무엇이 아직 안 열렸는지를 여기서 한 번 보여준다.
|
|
816
|
+
// 이 출력은 지나가면 끝이므로 다시 보는 법도 함께 알린다 (B-21).
|
|
817
|
+
console.log(`\n${renderSetup(await inspectSetup(ascRoot))}`);
|
|
818
|
+
console.log('\nAttached. Issue the first session with `asc session issue`.');
|
|
819
|
+
console.log('You can see this summary again any time with `asc setup status`.');
|
|
820
|
+
return 0;
|
|
821
|
+
}
|
|
822
|
+
/** 붙어 있으면 무엇으로 붙었는지 읽는다. 안 붙었거나 어긋났으면 없는 것으로 본다. */
|
|
823
|
+
async function attachedRuntime(ascRoot) {
|
|
824
|
+
const outcome = await bootstrapGuard({
|
|
825
|
+
ascRoot,
|
|
826
|
+
installRoot: installRoot(),
|
|
827
|
+
capabilities: CAPABILITIES,
|
|
828
|
+
adapters: ADAPTER_VERSIONS,
|
|
829
|
+
ascVersion: ASC_VERSION,
|
|
830
|
+
}).catch(() => null);
|
|
831
|
+
return outcome?.ok ? outcome.runtime : undefined;
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Profile이 이미 선언한 정책. **추측하지 않는다** — 선언 자리가 있고 실제로 채워진 것만 센다.
|
|
835
|
+
*
|
|
836
|
+
* 자리 자체가 없는 정책(작업 항목 정본·기본 전달 채널)은 언제나 미정으로 남는다.
|
|
837
|
+
* 그것이 지금 사실이고, 없는 자리를 있는 척하면 사람이 정할 기회를 잃는다.
|
|
838
|
+
*/
|
|
839
|
+
function declaredPolicies(resolved) {
|
|
840
|
+
if (!resolved)
|
|
841
|
+
return [];
|
|
842
|
+
const declared = [];
|
|
843
|
+
if (resolved.layers.profile.canonical.sources.length > 0)
|
|
844
|
+
declared.push('canonical');
|
|
845
|
+
if (resolved.ownership && Object.keys(resolved.ownership).length > 0) {
|
|
846
|
+
declared.push('ownership');
|
|
847
|
+
// 결정권은 ownership 안에 선언된다 — 하나라도 authority를 든 역할이 있어야 정해진 것이다.
|
|
848
|
+
if (Object.values(resolved.ownership).some((role) => (role.authorities ?? []).length > 0)) {
|
|
849
|
+
declared.push('authority');
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return declared;
|
|
853
|
+
}
|
|
854
|
+
const ASC_VERSION = '0.1.0';
|
|
855
|
+
const CAPABILITIES = ['scm.github', 'state.markdown', 'approval.local'];
|
|
856
|
+
const ADAPTER_VERSIONS = { 'scm.github': ASC_VERSION, 'state.markdown': ASC_VERSION };
|
|
857
|
+
/**
|
|
858
|
+
* 어느 build를 쓸 것인가 (C-14 §4). **project를 건드리지 않는다** — 이 선택은
|
|
859
|
+
* machine-local이고, 바꿔도 저장소에는 아무 일도 일어나지 않는다 (불변식 ⑤).
|
|
860
|
+
*/
|
|
861
|
+
async function runRuntimeSelect(command, mode, source, values) {
|
|
862
|
+
const home = ascHome();
|
|
863
|
+
if (command === 'status') {
|
|
864
|
+
const selection = await readRuntimeSelection(home);
|
|
865
|
+
const target = await resolveRuntimeTarget(selection);
|
|
866
|
+
if (values.json) {
|
|
867
|
+
console.log(JSON.stringify({
|
|
868
|
+
selection: selection ?? null,
|
|
869
|
+
target,
|
|
870
|
+
file: selectionPath(home),
|
|
871
|
+
// 해법은 데이터로 준다 — agent가 산문에서 경로를 추론하지 않는다
|
|
872
|
+
...('code' in target ? { action: remediationAction(target) } : {}),
|
|
873
|
+
}, null, 2));
|
|
874
|
+
return 'code' in target ? 1 : 0;
|
|
875
|
+
}
|
|
876
|
+
if ('code' in target) {
|
|
877
|
+
for (const line of remediationLines(target))
|
|
878
|
+
console.error(line);
|
|
879
|
+
return 1;
|
|
880
|
+
}
|
|
881
|
+
console.log(runtimeSelectionLine(target));
|
|
882
|
+
return 0;
|
|
883
|
+
}
|
|
884
|
+
if (mode !== 'package' && mode !== 'development') {
|
|
885
|
+
console.error('Usage: asc runtime use package | asc runtime use development <checkout>');
|
|
886
|
+
return 2;
|
|
887
|
+
}
|
|
888
|
+
const selection = mode === 'package'
|
|
889
|
+
? { version: 1, runtime: { mode: 'package' } }
|
|
890
|
+
: { version: 1, runtime: { mode: 'development', source: resolve(source ?? '') } };
|
|
891
|
+
if (mode === 'development' && !source) {
|
|
892
|
+
console.error('development needs a checkout path: asc runtime use development <path>');
|
|
893
|
+
return 2;
|
|
894
|
+
}
|
|
895
|
+
// **쓰기 전에 검증한다** (C-14 §10.1). 나쁜 선택을 저장해 두고 다음 명령에서 죽게 하지 않는다.
|
|
896
|
+
const target = await resolveRuntimeTarget(selection);
|
|
897
|
+
if ('code' in target) {
|
|
898
|
+
for (const line of remediationLines(target))
|
|
899
|
+
console.error(line);
|
|
900
|
+
return 1;
|
|
901
|
+
}
|
|
902
|
+
await writeRuntimeSelection(home, selection);
|
|
903
|
+
console.log(runtimeSelectionLine(target));
|
|
904
|
+
console.log(`Recorded in: ${selectionPath(home)}`);
|
|
905
|
+
return 0;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* 선택된 build가 지금 도는 실행물과 다르면 그쪽으로 넘긴다 (C-14 §4).
|
|
909
|
+
*
|
|
910
|
+
* **자기 자신으로 되튀지 않는다** (§4.2) — development checkout의 bin을 직접 부른 경우
|
|
911
|
+
* resolve 결과가 곧 자기 자신이고, 그때 재실행하면 끝없이 돈다.
|
|
912
|
+
*/
|
|
913
|
+
async function redispatchIfNeeded(argv) {
|
|
914
|
+
const selection = await readRuntimeSelection(ascHome());
|
|
915
|
+
if (!selection || selection.runtime.mode !== 'development')
|
|
916
|
+
return null;
|
|
917
|
+
const target = await resolveRuntimeTarget(selection);
|
|
918
|
+
if ('code' in target) {
|
|
919
|
+
for (const line of remediationLines(target))
|
|
920
|
+
console.error(line);
|
|
921
|
+
return 1;
|
|
922
|
+
}
|
|
923
|
+
if (target.kind !== 'development')
|
|
924
|
+
return null;
|
|
925
|
+
const here = fileURLToPath(import.meta.url);
|
|
926
|
+
if (target.entry === here)
|
|
927
|
+
return null;
|
|
928
|
+
const child = spawnSync(process.execPath, [target.entry, ...argv], { stdio: 'inherit' });
|
|
929
|
+
// 신호로 죽은 것을 성공으로 보고하지 않는다 — 감독자가 죽음을 봐야 한다
|
|
930
|
+
if (child.signal)
|
|
931
|
+
return 128 + (SIGNAL_NUMBERS[child.signal] ?? 15);
|
|
932
|
+
return child.status ?? 0;
|
|
933
|
+
}
|
|
934
|
+
const SIGNAL_NUMBERS = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
|
|
935
|
+
/**
|
|
936
|
+
* 지금 무엇이 되고 무엇이 막혀 있는가 (B-21). 판정만 하고 아무것도 고치지 않는다.
|
|
937
|
+
* 붙기 전에도 답을 줘야 하므로 root 탐색 실패를 오류로 다루지 않는다.
|
|
938
|
+
*/
|
|
939
|
+
async function runSetup(command, values, entry = 'runtime') {
|
|
940
|
+
// plan/apply는 같은 판단을 나눠 쓴다 (C-14 §6). `--agent` 는 apply의 비대화 형태다.
|
|
941
|
+
if (command === 'plan' || command === 'apply')
|
|
942
|
+
return runSetupLifecycle(command, values, entry);
|
|
943
|
+
if (values.agent)
|
|
944
|
+
return runSetupLifecycle('apply', values, entry);
|
|
945
|
+
if (command !== undefined && command !== 'status') {
|
|
946
|
+
console.error(`Unknown setup command: ${command}\n\n${USAGE}`);
|
|
947
|
+
return 2;
|
|
948
|
+
}
|
|
949
|
+
const resolution = await resolveRoot(process.cwd(), values.root);
|
|
950
|
+
const root = resolution.kind === 'UNRESOLVED' ? null : resolution.root;
|
|
951
|
+
const status = root
|
|
952
|
+
? await inspectSetup(root)
|
|
953
|
+
: assessSetup({
|
|
954
|
+
attachment: 'UNATTACHED',
|
|
955
|
+
hasApprovers: false,
|
|
956
|
+
hasControllerIdentities: false,
|
|
957
|
+
hasMonitorIdentities: false,
|
|
958
|
+
hasScmToken: await hasToken(),
|
|
959
|
+
});
|
|
960
|
+
if (values.json) {
|
|
961
|
+
console.log(JSON.stringify({ ...status, runtime: resolution }, null, 2));
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
// 어느 뿌리를 왜 골랐는지 먼저 말한다 — 틀린 결합은 여기서 알아채는 게 가장 싸다
|
|
965
|
+
console.log(resolutionLine(resolution));
|
|
966
|
+
console.log(renderSetup(status));
|
|
967
|
+
}
|
|
968
|
+
// 진단이지 실패가 아니다 — 막힌 게 있어도 0이다
|
|
969
|
+
return 0;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* 세상을 읽어 `SetupState` 를 만든다. **읽기만 한다** (C-14 §6).
|
|
973
|
+
*
|
|
974
|
+
* Core의 planner는 파일도 network도 모른다 — 사실은 여기서 관측해 넘긴다. 그래야
|
|
975
|
+
* "이 명령이 무엇을 바꿀 것인가"를 아무것도 바꾸지 않고 물어볼 수 있다.
|
|
976
|
+
*/
|
|
977
|
+
async function detectSetupState(values, entry) {
|
|
978
|
+
const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
|
|
979
|
+
const resolution = await resolveRoot(process.cwd(), values.root);
|
|
980
|
+
const ascRoot = resolution.kind === 'UNRESOLVED' ? undefined : resolution.root;
|
|
981
|
+
const scope = values.scope === 'project' ? 'project' : 'local';
|
|
982
|
+
const hostReport = await verifyInstall(defaultPaths());
|
|
983
|
+
return {
|
|
984
|
+
projectRoot,
|
|
985
|
+
git,
|
|
986
|
+
...(ascRoot ? { ascRoot } : {}),
|
|
987
|
+
...(values.profile ? { requestedProfile: values.profile } : {}),
|
|
988
|
+
profileCandidates: await availableProfiles(installRoot()),
|
|
989
|
+
scope,
|
|
990
|
+
host: [{ id: 'claude', status: hostReport.status }],
|
|
991
|
+
// **bootstrap으로 들어왔을 때만 본다.** 설치된 runtime이 자기를 다시 설치할 이유가
|
|
992
|
+
// 없고, 그 축을 안 그리면 plan은 설치된 `asc` 를 전제하지도 않는다 (C-14 §3.4).
|
|
993
|
+
...(entry === 'bootstrap' ? { stableRuntime: await detectStableInstall(nodeProcessRunner) } : {}),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* `npm` 을 shim으로 부르지 않는다.
|
|
998
|
+
*
|
|
999
|
+
* Windows에서 `npm` 은 `npm.cmd` 이고, Node는 보안 수정 이후 shell 없이 `.cmd` 를 실행하지
|
|
1000
|
+
* 않는다 — 그대로 두면 `asc setup status` 가 전역 설치를 조회하지 못하고 "설치 안 됨"으로
|
|
1001
|
+
* 잘못 답한다. shell을 켜는 것은 답이 아니다(인자가 escape 없이 이어붙는다). npm의 진입
|
|
1002
|
+
* JS를 찾아 지금 도는 node로 직접 돌리면 세 OS에서 같은 실행 경로가 된다.
|
|
1003
|
+
*
|
|
1004
|
+
* 못 찾으면 이름 그대로 부른다 — PATH에 진짜 `npm` 실행 파일이 있는 환경이 그 경우다.
|
|
1005
|
+
*/
|
|
1006
|
+
function resolveCommand(command, args) {
|
|
1007
|
+
if (command !== 'npm')
|
|
1008
|
+
return [command, [...args]];
|
|
1009
|
+
const base = dirname(process.execPath);
|
|
1010
|
+
for (const candidate of [
|
|
1011
|
+
join(base, 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows 설치 배치
|
|
1012
|
+
join(base, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // POSIX 설치 배치
|
|
1013
|
+
]) {
|
|
1014
|
+
if (existsSync(candidate))
|
|
1015
|
+
return [process.execPath, [candidate, ...args]];
|
|
1016
|
+
}
|
|
1017
|
+
return [command, [...args]];
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* 바깥 명령 하나를 돌린다. **주입 가능한 seam이다** (C-14 §11) — 테스트는 가짜를 넣어
|
|
1021
|
+
* 사용자의 전역 npm·HOME·PATH를 건드리지 않는다.
|
|
1022
|
+
*/
|
|
1023
|
+
const nodeProcessRunner = async (command, args) => {
|
|
1024
|
+
const [runnable, runArgs] = resolveCommand(command, args);
|
|
1025
|
+
try {
|
|
1026
|
+
const { stdout, stderr } = await execFileAsync(runnable, runArgs);
|
|
1027
|
+
return { ok: true, stdout, stderr };
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
const failure = error;
|
|
1031
|
+
return { ok: false, stdout: failure.stdout ?? '', stderr: failure.stderr ?? failure.message ?? '' };
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
/**
|
|
1035
|
+
* detect → plan → (apply) → verify 한 바퀴.
|
|
1036
|
+
*
|
|
1037
|
+
* 사람이 보는 줄과 agent가 파싱하는 JSON은 **같은 plan**에서 나온다 (C-14 불변식 ①).
|
|
1038
|
+
* agent 경로의 stdout은 JSON 문서 하나뿐이고, 그 밖의 말은 stderr로 간다 (§7).
|
|
1039
|
+
*/
|
|
1040
|
+
async function runSetupLifecycle(command, values, entry) {
|
|
1041
|
+
const asJson = Boolean(values.json) || Boolean(values.agent);
|
|
1042
|
+
const plan = computeSetupPlan(await detectSetupState(values, entry));
|
|
1043
|
+
const emit = (payload) => {
|
|
1044
|
+
if (asJson)
|
|
1045
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1046
|
+
else
|
|
1047
|
+
for (const line of renderSetupPlan(plan))
|
|
1048
|
+
console.log(line);
|
|
1049
|
+
};
|
|
1050
|
+
if (command === 'plan' || plan.requiresUserAction) {
|
|
1051
|
+
// plan은 아무것도 바꾸지 않는다. 사람이 답해야 하면 apply도 여기서 멈춘다.
|
|
1052
|
+
emit({ ...plan, changesApplied: false });
|
|
1053
|
+
return plan.requiresUserAction ? 1 : 0;
|
|
1054
|
+
}
|
|
1055
|
+
// apply가 부르는 기존 명령들은 사람에게 말하도록 만들어졌다. agent 경로에서 그 산문이
|
|
1056
|
+
// stdout에 섞이면 JSON 문서 하나라는 계약이 깨진다 (C-14 §7) — 진단이므로 stderr로 보낸다.
|
|
1057
|
+
const speak = console.log;
|
|
1058
|
+
if (asJson)
|
|
1059
|
+
console.log = console.error;
|
|
1060
|
+
let outcome;
|
|
1061
|
+
try {
|
|
1062
|
+
outcome = await applySetupPlan(plan, {
|
|
1063
|
+
installRuntime: async (change) => {
|
|
1064
|
+
// 설치는 npm의 몫이다 — ASC는 shell도 PATH도 고치지 않는다 (C-14 불변식 ⑰)
|
|
1065
|
+
const installed = await installStableRuntime(nodeProcessRunner, change.version);
|
|
1066
|
+
if (!installed.ok)
|
|
1067
|
+
throw new Error(`runtime install failed: ${installed.detail ?? '(no detail)'}`);
|
|
1068
|
+
// exit 0은 "npm이 화내지 않았다"까지다. 실제로 서는지까지 본다 (C-14 §3.3)
|
|
1069
|
+
const verified = await verifyStableInstall(nodeProcessRunner, change.version);
|
|
1070
|
+
if (!verified.ok)
|
|
1071
|
+
throw new Error(verified.remedy ?? 'runtime install could not be verified');
|
|
1072
|
+
},
|
|
1073
|
+
attachWorkspace: async (change) => {
|
|
1074
|
+
const code = await runInit({ ...values, profile: change.profile, scope: change.scope });
|
|
1075
|
+
if (code !== 0)
|
|
1076
|
+
throw new Error(`attach 실패 (exit ${code})`);
|
|
1077
|
+
},
|
|
1078
|
+
installHost: async (change) => {
|
|
1079
|
+
const code = await runHost('claude', 'install', undefined, { ...values, json: false });
|
|
1080
|
+
if (code !== 0)
|
|
1081
|
+
throw new Error(`${change.host} 설치 실패 (exit ${code})`);
|
|
1082
|
+
},
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
finally {
|
|
1086
|
+
console.log = speak;
|
|
1087
|
+
}
|
|
1088
|
+
// verify — 같은 detect로 다시 본다. 멱등이면 남은 변경이 없어야 한다.
|
|
1089
|
+
const after = computeSetupPlan(await detectSetupState(values, entry));
|
|
1090
|
+
const verified = after.changes.length === 0;
|
|
1091
|
+
emit({
|
|
1092
|
+
...plan,
|
|
1093
|
+
status: verified ? 'applied' : 'verification_failed',
|
|
1094
|
+
changesApplied: outcome.changesApplied,
|
|
1095
|
+
remaining: after.changes,
|
|
1096
|
+
});
|
|
1097
|
+
return verified ? 0 : 1;
|
|
1098
|
+
}
|
|
1099
|
+
/** 파일·env를 읽어 판정 입력을 모은다. Core는 이것들을 직접 읽지 않는다. */
|
|
1100
|
+
async function inspectSetup(root) {
|
|
1101
|
+
const outcome = await bootstrapGuard({
|
|
1102
|
+
ascRoot: root,
|
|
1103
|
+
installRoot: installRoot(),
|
|
1104
|
+
capabilities: CAPABILITIES,
|
|
1105
|
+
adapters: ADAPTER_VERSIONS,
|
|
1106
|
+
ascVersion: ASC_VERSION,
|
|
1107
|
+
});
|
|
1108
|
+
const attachment = outcome.ok
|
|
1109
|
+
? 'READY'
|
|
1110
|
+
: outcome.reason === 'NOT_ATTACHED'
|
|
1111
|
+
? 'UNATTACHED'
|
|
1112
|
+
: outcome.reason === 'LOCK_DRIFT'
|
|
1113
|
+
? 'LOCK_DRIFT'
|
|
1114
|
+
: 'BROKEN';
|
|
1115
|
+
const runtime = outcome.ok ? outcome.runtime : undefined;
|
|
1116
|
+
return assessSetup({
|
|
1117
|
+
attachment,
|
|
1118
|
+
hasApprovers: Object.keys(await loadIdentityMap(root)).length > 0,
|
|
1119
|
+
hasControllerIdentities: Object.keys(runtime?.controllerIdentities ?? {}).length > 0,
|
|
1120
|
+
hasMonitorIdentities: (runtime?.monitor.identities?.length ?? 0) > 0,
|
|
1121
|
+
hasScmToken: await hasToken(),
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
const hasToken = async () => (await discoverToken()) !== null;
|
|
1125
|
+
/**
|
|
1126
|
+
* 모든 Run 앞에 서는 문. 지금 계층을 다시 합쳐 lock과 견주고, 어긋나면 멈춘다.
|
|
1127
|
+
* 저절로 다시 맞추지 않는다 — 설정이 바뀐 채 계속 돌면 그 Run이 무엇을 근거로 판단했는지
|
|
1128
|
+
* 나중에 알 수 없다.
|
|
1129
|
+
*/
|
|
1130
|
+
/** 이 저장소(또는 설치된 패키지)의 뿌리. profiles/ · presets/ 를 여기서 읽는다. */
|
|
1131
|
+
const installRoot = () => join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
1132
|
+
async function checkBootstrap(root) {
|
|
1133
|
+
const outcome = await bootstrapGuard({
|
|
1134
|
+
ascRoot: root,
|
|
1135
|
+
installRoot: installRoot(),
|
|
1136
|
+
capabilities: CAPABILITIES,
|
|
1137
|
+
adapters: ADAPTER_VERSIONS,
|
|
1138
|
+
ascVersion: ASC_VERSION,
|
|
1139
|
+
});
|
|
1140
|
+
if (outcome.ok)
|
|
1141
|
+
return { code: 0, runtime: outcome.runtime };
|
|
1142
|
+
// 아직 붙이지 않았으면 설정 없이 도는 경로만 쓰는 것이므로 막지 않는다
|
|
1143
|
+
if (outcome.reason === 'NOT_ATTACHED')
|
|
1144
|
+
return { code: 0 };
|
|
1145
|
+
if (outcome.reason === 'BROKEN_ATTACHMENT') {
|
|
1146
|
+
// 붙이다 만 상태다. 무엇으로 도는지 모르는 채 굴러가는 것보다 멈추는 편이 낫다.
|
|
1147
|
+
console.error(`The runtime is not intact: ${outcome.detail}`);
|
|
1148
|
+
console.error('Re-attach with `asc init --profile <id>`, or lock it with `asc profile resolve --write`.');
|
|
1149
|
+
return { code: 2 };
|
|
1150
|
+
}
|
|
1151
|
+
if (outcome.reason === 'RESOLVE_FAILED') {
|
|
1152
|
+
console.error('Configuration could not be read:');
|
|
1153
|
+
for (const detail of outcome.details)
|
|
1154
|
+
console.error(` - ${detail}`);
|
|
1155
|
+
return { code: 2 };
|
|
1156
|
+
}
|
|
1157
|
+
console.error('Configuration differs from profile.lock. Nothing proceeds until it is settled:');
|
|
1158
|
+
for (const drift of outcome.drifts)
|
|
1159
|
+
console.error(` ${drift.field}: ${drift.locked} → ${drift.current}`);
|
|
1160
|
+
console.error('\nOnce you have checked it, re-lock with `asc profile resolve --write`.');
|
|
1161
|
+
return { code: 2 };
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* 계층을 합쳐 산출물 셋을 만든다. 기본은 미리보기다 — 무엇이 바뀌는지 보고 나서 쓴다.
|
|
1165
|
+
* lock이 어긋나면 알리기만 하고 저절로 맞추지 않는다 (OM §4.9).
|
|
1166
|
+
*/
|
|
1167
|
+
async function runProfile(command, values, root) {
|
|
1168
|
+
if (command !== 'resolve') {
|
|
1169
|
+
console.error(`Unknown profile command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
1170
|
+
return 2;
|
|
1171
|
+
}
|
|
1172
|
+
if (!values.profile) {
|
|
1173
|
+
console.error('Usage: asc profile resolve --profile <id> [--preset <id>]');
|
|
1174
|
+
return 2;
|
|
1175
|
+
}
|
|
1176
|
+
const installPath = values.install ?? installRoot();
|
|
1177
|
+
const layers = await loadLayers({
|
|
1178
|
+
installRoot: installPath,
|
|
1179
|
+
profileId: values.profile,
|
|
1180
|
+
...(values.preset ? { presetId: values.preset } : {}),
|
|
1181
|
+
overridePath: join(root, 'override.json'),
|
|
1182
|
+
});
|
|
1183
|
+
// 무엇이 실제로 제공되는지는 붙어 있는 Adapter가 정한다
|
|
1184
|
+
const capabilities = ['scm.github', 'state.markdown', 'approval.local'];
|
|
1185
|
+
const result = resolveRuntime(layers, capabilities, ASC_VERSION);
|
|
1186
|
+
if (!result.ok) {
|
|
1187
|
+
console.error('resolve failed:');
|
|
1188
|
+
for (const failure of result.failures) {
|
|
1189
|
+
console.error(` - ${failure.kind}: ${'detail' in failure ? failure.detail : failure.violation.detail}`);
|
|
1190
|
+
}
|
|
1191
|
+
return 1;
|
|
1192
|
+
}
|
|
1193
|
+
const runtime = result.runtime;
|
|
1194
|
+
const generatedAt = new Date().toISOString();
|
|
1195
|
+
const lock = buildLock({
|
|
1196
|
+
runtime,
|
|
1197
|
+
ascVersion: ASC_VERSION,
|
|
1198
|
+
adapters: { 'scm.github': ASC_VERSION, 'state.markdown': ASC_VERSION },
|
|
1199
|
+
generatedAt,
|
|
1200
|
+
});
|
|
1201
|
+
console.log(`Layers: ${runtime.resolved.policy.layers.join(' → ')}`);
|
|
1202
|
+
console.log(`Canonical: ${runtime.canonicalSources.join(', ')}`);
|
|
1203
|
+
console.log(`Enabled: ${runtime.resolved.capabilities.join(', ') || 'none'}`);
|
|
1204
|
+
if (runtime.degraded.length > 0)
|
|
1205
|
+
console.log(`Disabled: ${runtime.degraded.join(', ')}`);
|
|
1206
|
+
// 이미 lock이 있으면 견준다. 고쳐 주지 않고 무엇이 달라졌는지만 말한다.
|
|
1207
|
+
try {
|
|
1208
|
+
const existing = ProfileLock.parse(JSON.parse(await readFile(join(root, 'profile.lock'), 'utf8')));
|
|
1209
|
+
const drifts = compareLock(existing, lock);
|
|
1210
|
+
if (drifts.length > 0) {
|
|
1211
|
+
console.log('\nConfiguration differs from the lock:');
|
|
1212
|
+
for (const drift of drifts)
|
|
1213
|
+
console.log(` ${drift.field}: ${drift.locked} → ${drift.current}`);
|
|
1214
|
+
console.log('Check what changed, and if this is the combination you want, re-lock it with --write.');
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
if (error.code !== 'ENOENT')
|
|
1219
|
+
throw error;
|
|
1220
|
+
}
|
|
1221
|
+
if (!values.write) {
|
|
1222
|
+
console.log('\n(preview — pass --write to actually write)');
|
|
1223
|
+
return 0;
|
|
1224
|
+
}
|
|
1225
|
+
// 이전 lock은 덮지 않고 옮긴다 — 어떤 조합으로 돌렸는지가 사라지면 재현할 수 없다
|
|
1226
|
+
try {
|
|
1227
|
+
const previous = ProfileLock.parse(JSON.parse(await readFile(join(root, 'profile.lock'), 'utf8')));
|
|
1228
|
+
console.log(`Previous lock archived at: ${await archiveLock(root, previous)}`);
|
|
1229
|
+
}
|
|
1230
|
+
catch (error) {
|
|
1231
|
+
if (error.code !== 'ENOENT')
|
|
1232
|
+
throw error;
|
|
1233
|
+
}
|
|
1234
|
+
await mkdir(join(root, 'cache'), { recursive: true });
|
|
1235
|
+
await writeFile(join(root, 'ASC.md'), renderAscMd(runtime, generatedAt), 'utf8');
|
|
1236
|
+
await writeFile(join(root, 'cache', 'resolved-profile.json'), JSON.stringify(runtime.resolved, null, 2), 'utf8');
|
|
1237
|
+
await writeFile(join(root, 'profile.lock'), JSON.stringify(lock, null, 2), 'utf8');
|
|
1238
|
+
await writeFile(join(root, 'profile'), `${layers.profile.id}\n`, 'utf8');
|
|
1239
|
+
// controller.md는 사람이 쓰는 파일이라 한 번만 만들고 이후엔 손대지 않는다
|
|
1240
|
+
try {
|
|
1241
|
+
await writeFile(join(root, 'controller.md'), renderControllerMd(layers.profile.id), { encoding: 'utf8', flag: 'wx' });
|
|
1242
|
+
}
|
|
1243
|
+
catch (error) {
|
|
1244
|
+
if (error.code !== 'EEXIST')
|
|
1245
|
+
throw error;
|
|
1246
|
+
}
|
|
1247
|
+
console.log('\nWrote ASC.md, cache/resolved-profile.json and profile.lock');
|
|
1248
|
+
return 0;
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Profile이 정의한 정본을 실제로 읽을 통로. 토큰이 없으면 만들지 않는다 —
|
|
1252
|
+
* 그 경우 baseline 기록·대조는 건너뛰고, 건너뛰었다는 사실이 계약에 빈 snapshot으로 남는다.
|
|
1253
|
+
*/
|
|
1254
|
+
async function scmFor(resolved) {
|
|
1255
|
+
if (!resolved)
|
|
1256
|
+
return undefined;
|
|
1257
|
+
const token = await discoverToken();
|
|
1258
|
+
if (!token)
|
|
1259
|
+
return undefined;
|
|
1260
|
+
const sourceRefs = {};
|
|
1261
|
+
for (const source of resolved.layers.profile.canonical.sources) {
|
|
1262
|
+
if (source.ref)
|
|
1263
|
+
sourceRefs[source.id] = { ref: source.ref };
|
|
1264
|
+
}
|
|
1265
|
+
return new GitHubScm({
|
|
1266
|
+
client: new GitHubClient({ token }),
|
|
1267
|
+
defaultRepo: resolved.layers.profile.project.repository,
|
|
1268
|
+
sourceRefs,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Claude Host Adapter 표면 (C-03 §5). install/uninstall/probe는 프로젝트 밖(user-scope)
|
|
1273
|
+
* 이라 .asc 없이 돌고, bind/contract는 attach된 프로젝트에서 돈다.
|
|
1274
|
+
*/
|
|
1275
|
+
async function runHost(provider, command, target, values) {
|
|
1276
|
+
if (provider !== 'claude') {
|
|
1277
|
+
console.error(`Unknown host: ${provider ?? '(none)'} — only claude is supported today`);
|
|
1278
|
+
return 2;
|
|
1279
|
+
}
|
|
1280
|
+
const paths = defaultPaths();
|
|
1281
|
+
switch (command) {
|
|
1282
|
+
case 'install': {
|
|
1283
|
+
const before = await verifyInstall(paths);
|
|
1284
|
+
if (before.status === 'INSTALLED_STALE')
|
|
1285
|
+
console.log('The installation is behind the current source — converging it.');
|
|
1286
|
+
const outcome = await install(paths, undefined, { force: Boolean(values.force) });
|
|
1287
|
+
for (const path of outcome.written)
|
|
1288
|
+
console.log(`installed: ${path}`);
|
|
1289
|
+
for (const skip of outcome.skipped)
|
|
1290
|
+
console.log(`skipped: ${skip.path} — ${skip.reason}`);
|
|
1291
|
+
if (outcome.written.length === 0 && outcome.skipped.length === 0)
|
|
1292
|
+
console.log('Already installed (no change)');
|
|
1293
|
+
// 무엇이 남았는지 install 직후에 말한다 — 사람이 따로 물어보게 하지 않는다
|
|
1294
|
+
for (const line of installReportLines(await verifyInstall(paths)))
|
|
1295
|
+
console.log(line);
|
|
1296
|
+
console.log('\nThe guard hook is registered. External writes from ASC-managed sessions are blocked at execution time.');
|
|
1297
|
+
return outcome.skipped.length > 0 ? 1 : 0;
|
|
1298
|
+
}
|
|
1299
|
+
case 'uninstall': {
|
|
1300
|
+
const outcome = await uninstall(paths);
|
|
1301
|
+
for (const path of outcome.removed)
|
|
1302
|
+
console.log(`removed: ${path}`);
|
|
1303
|
+
for (const keep of outcome.kept)
|
|
1304
|
+
console.log(`kept: ${keep.path} — ${keep.reason}`);
|
|
1305
|
+
if (outcome.removed.length === 0)
|
|
1306
|
+
console.log('Nothing installed by ASC to remove');
|
|
1307
|
+
return 0;
|
|
1308
|
+
}
|
|
1309
|
+
case 'guard': {
|
|
1310
|
+
// 2층 — worker 세션에만 적용되는 permission deny. user-scope에 넣으면 사람의
|
|
1311
|
+
// git push까지 전역으로 막히므로, worker 기동 시 --settings 로 주입하는 파일로 둔다.
|
|
1312
|
+
const root = await discoverRoot(process.cwd(), values.root);
|
|
1313
|
+
if (!root) {
|
|
1314
|
+
console.error('No attached ASC runtime found — run this inside an attached project, or pass --root.');
|
|
1315
|
+
return 2;
|
|
1316
|
+
}
|
|
1317
|
+
const guardPath = join(root, 'adapters', 'claude-code', 'worker-settings.json');
|
|
1318
|
+
await mkdir(dirname(guardPath), { recursive: true });
|
|
1319
|
+
await writeFile(guardPath, workerSettings(), 'utf8');
|
|
1320
|
+
console.log(`created: ${guardPath}`);
|
|
1321
|
+
console.log('Inject this whenever an ASC-managed worker starts:');
|
|
1322
|
+
console.log(` claude --settings "${guardPath}" ...`);
|
|
1323
|
+
return 0;
|
|
1324
|
+
}
|
|
1325
|
+
case 'probe': {
|
|
1326
|
+
const report = await verifyInstall(paths);
|
|
1327
|
+
const installed = report.status === 'INSTALLED_CURRENT';
|
|
1328
|
+
// 2층 판정 — attach된 프로젝트에서만 확인 가능하다
|
|
1329
|
+
const probeRoot = await discoverRoot(process.cwd(), values.root);
|
|
1330
|
+
let workerSettingsReady;
|
|
1331
|
+
if (probeRoot) {
|
|
1332
|
+
const guardPath = join(probeRoot, 'adapters', 'claude-code', 'worker-settings.json');
|
|
1333
|
+
workerSettingsReady = await readFile(guardPath, 'utf8')
|
|
1334
|
+
.then((text) => text === workerSettings())
|
|
1335
|
+
.catch(() => false);
|
|
1336
|
+
}
|
|
1337
|
+
let result = await probe({
|
|
1338
|
+
guardInstalled: installed,
|
|
1339
|
+
...(workerSettingsReady !== undefined ? { workerSettingsReady } : {}),
|
|
1340
|
+
});
|
|
1341
|
+
// 호스트 세션이 자기 도구 목록을 보고 채우는 self-report (--report cap=true)
|
|
1342
|
+
const reports = values.report ?? [];
|
|
1343
|
+
if (reports.length > 0) {
|
|
1344
|
+
const parsed = {};
|
|
1345
|
+
for (const entry of reports) {
|
|
1346
|
+
const [name, value] = entry.split('=');
|
|
1347
|
+
if (name && (value === 'true' || value === 'false')) {
|
|
1348
|
+
parsed[name] = value === 'true';
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
result = applyHostReport(result, parsed, new Date().toISOString());
|
|
1352
|
+
}
|
|
1353
|
+
// capability 표보다 먼저 설치 상태를 말한다 — 낡은 설치본이면 아래 판정도 낡은 것이다
|
|
1354
|
+
for (const line of installReportLines(report))
|
|
1355
|
+
console.log(line);
|
|
1356
|
+
console.log(`Claude Code: ${result.claudeVersion ?? '(not found)'}`);
|
|
1357
|
+
for (const [name, verdict] of Object.entries(result.capabilities)) {
|
|
1358
|
+
const mark = verdict.available === true ? 'O' : verdict.available === false ? 'X' : '?';
|
|
1359
|
+
console.log(` ${mark} ${name.padEnd(24)} [${verdict.source}] ${verdict.detail ?? ''}`);
|
|
1360
|
+
}
|
|
1361
|
+
const readiness = assessReadiness(result);
|
|
1362
|
+
if (!readiness.ok) {
|
|
1363
|
+
console.error(`\nSTOP: a safety-critical capability is missing — ${readiness.missing.join(', ')}`);
|
|
1364
|
+
console.error('Without the external-write guard, no ASC-managed autonomous worker runs.');
|
|
1365
|
+
}
|
|
1366
|
+
else if (readiness.degraded.length > 0) {
|
|
1367
|
+
console.log(`\ndegraded (optional capability missing or unverified): ${readiness.degraded.join(', ')}`);
|
|
1368
|
+
}
|
|
1369
|
+
// attach된 프로젝트면 결과를 Adapter metadata로 남긴다
|
|
1370
|
+
if (probeRoot) {
|
|
1371
|
+
const store = new MarkdownStateStore(probeRoot);
|
|
1372
|
+
await store.scope('claude-code').set('capabilities', JSON.stringify(result));
|
|
1373
|
+
// 뿌리는 workspace마다 다르다 — `.asc/` 로 적으면 local scope에서 없는 경로를 가리킨다
|
|
1374
|
+
console.log(`\nRecorded in: ${join(probeRoot, 'adapters', 'claude-code', 'capabilities.json')}`);
|
|
1375
|
+
}
|
|
1376
|
+
return readiness.ok ? 0 : 1;
|
|
1377
|
+
}
|
|
1378
|
+
case 'bind':
|
|
1379
|
+
case 'release':
|
|
1380
|
+
case 'contract': {
|
|
1381
|
+
if (!target) {
|
|
1382
|
+
console.error(`Usage: asc host claude ${command} <S-ID> ...`);
|
|
1383
|
+
return 2;
|
|
1384
|
+
}
|
|
1385
|
+
const root = await discoverRoot(process.cwd(), values.root);
|
|
1386
|
+
if (!root) {
|
|
1387
|
+
console.error('No attached ASC runtime found — run this inside an attached project.');
|
|
1388
|
+
return 2;
|
|
1389
|
+
}
|
|
1390
|
+
const store = new MarkdownStateStore(root);
|
|
1391
|
+
const bindings = claudeBindings(store);
|
|
1392
|
+
const at = new Date().toISOString();
|
|
1393
|
+
if (command === 'contract') {
|
|
1394
|
+
const session = await store.get('session', target);
|
|
1395
|
+
if (!session) {
|
|
1396
|
+
console.error(`${target} was not found.`);
|
|
1397
|
+
return 1;
|
|
1398
|
+
}
|
|
1399
|
+
// 계약문이 Profile의 책임 지도를 인용하므로, 지금 설정이 lock과 같은지 먼저 본다.
|
|
1400
|
+
// 어긋난 설정에서 뽑은 결정권을 worker에게 건네면 그 세션은 틀린 전제로 돈다.
|
|
1401
|
+
const guard = await checkBootstrap(root);
|
|
1402
|
+
if (guard.code !== 0)
|
|
1403
|
+
return guard.code;
|
|
1404
|
+
console.log(workerContract({
|
|
1405
|
+
logicalSessionId: session.id,
|
|
1406
|
+
goal: session.goal,
|
|
1407
|
+
doneCriteria: session.doneCriteria,
|
|
1408
|
+
writeBoundary: session.writeBoundary,
|
|
1409
|
+
...(session.owner ? { owner: session.owner } : {}),
|
|
1410
|
+
// 세션이 명시한 것과 Profile 지도에서 풀린 것을 합쳐 넘긴다. worker에게 필요한 것은
|
|
1411
|
+
// "어디에 적혀 있는가"가 아니라 "이 결정이 누구 것인가"다.
|
|
1412
|
+
...(() => {
|
|
1413
|
+
const decided = effectiveAuthority(session, guard.runtime?.ownership);
|
|
1414
|
+
return Object.keys(decided).length > 0 ? { decisionAuthority: decided } : {};
|
|
1415
|
+
})(),
|
|
1416
|
+
...(session.dependencies.length > 0 ? { dependencies: session.dependencies } : {}),
|
|
1417
|
+
}));
|
|
1418
|
+
return 0;
|
|
1419
|
+
}
|
|
1420
|
+
if (!values.physical) {
|
|
1421
|
+
console.error('--physical <Claude session id> is required.');
|
|
1422
|
+
return 2;
|
|
1423
|
+
}
|
|
1424
|
+
const physical = values.physical;
|
|
1425
|
+
const audit = auditLedger(store);
|
|
1426
|
+
if (command === 'release') {
|
|
1427
|
+
const running = (await audit.executionsOf(target)).filter((e) => e.status === 'RUNNING' && e.physicalReference === physical);
|
|
1428
|
+
const released = await bindings.release(target, physical);
|
|
1429
|
+
// 소유권은 사라져도 그 실행이 있었다는 사실은 남는다 (C-10 §1.3)
|
|
1430
|
+
if (released)
|
|
1431
|
+
for (const evidence of running)
|
|
1432
|
+
await audit.endExecution(evidence.executionId, 'RELEASED', at);
|
|
1433
|
+
console.log(released ? `${target} ownership released` : 'Release failed — you are not the owner');
|
|
1434
|
+
return released ? 0 : 1;
|
|
1435
|
+
}
|
|
1436
|
+
const spec = {
|
|
1437
|
+
logicalSessionId: target,
|
|
1438
|
+
provider: CLAUDE_PROVIDER,
|
|
1439
|
+
physicalSessionId: physical,
|
|
1440
|
+
...(values.worker ? { workerId: values.worker } : {}),
|
|
1441
|
+
...(values.kind ? { runtimeKind: values.kind } : {}),
|
|
1442
|
+
};
|
|
1443
|
+
// principal은 physical 참조와 다르다 (C-10 §3). 선언이 없으면 유추한 것이고,
|
|
1444
|
+
// 유추한 principal 위에서는 어떤 독립성 주장도 UNVERIFIED를 넘지 못한다.
|
|
1445
|
+
const principal = values.principal ?? physical;
|
|
1446
|
+
const principalSource = values.principal ? 'declared' : 'derived';
|
|
1447
|
+
const recordExecution = async (evidenceSource) => {
|
|
1448
|
+
const recorded = await audit.execute({
|
|
1449
|
+
logicalSessionId: target,
|
|
1450
|
+
hostAdapter: CLAUDE_PROVIDER,
|
|
1451
|
+
principal,
|
|
1452
|
+
principalSource,
|
|
1453
|
+
physicalReference: physical,
|
|
1454
|
+
startedAt: at,
|
|
1455
|
+
evidenceSource,
|
|
1456
|
+
});
|
|
1457
|
+
console.log(`execution evidence ${recorded.evidence.executionId} · principal ${principal} (${principalSource})`);
|
|
1458
|
+
};
|
|
1459
|
+
if (values.force) {
|
|
1460
|
+
// 죽은 owner를 사람이 확인하고 갈아끼우는 명시적 복구다 — 자동 탈취가 아니다
|
|
1461
|
+
const superseded = (await audit.executionsOf(target)).filter((e) => e.status === 'RUNNING');
|
|
1462
|
+
const rebound = await bindings.rebind(spec, at);
|
|
1463
|
+
for (const evidence of superseded)
|
|
1464
|
+
await audit.endExecution(evidence.executionId, 'SUPERSEDED', at);
|
|
1465
|
+
await recordExecution('host bind --force');
|
|
1466
|
+
console.log(`${target} ← ${rebound.physicalSessionId} (explicit rebind)`);
|
|
1467
|
+
return 0;
|
|
1468
|
+
}
|
|
1469
|
+
const claimed = await bindings.claim(spec, at);
|
|
1470
|
+
if (!claimed.ok) {
|
|
1471
|
+
console.error(`RUNTIME_CONFLICT: ${target} 의 owner는 ${claimed.current.physicalSessionId} 다. ` +
|
|
1472
|
+
'죽은 세션이 확실하면 --force 로 rebind하라.');
|
|
1473
|
+
return 1;
|
|
1474
|
+
}
|
|
1475
|
+
await recordExecution('host bind');
|
|
1476
|
+
console.log(`${target} ← ${claimed.binding.physicalSessionId} (owner claim)`);
|
|
1477
|
+
console.log('This session is now ASC-managed — external writes are stopped by the guard.');
|
|
1478
|
+
return 0;
|
|
1479
|
+
}
|
|
1480
|
+
default:
|
|
1481
|
+
console.error(`Unknown host claude command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
1482
|
+
return 2;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* "ASC로 진행해"의 CLI 표면 (C-03 §1). 판단은 Operator가, 전이는 SessionRuntime이 —
|
|
1487
|
+
* 여기는 조립과 출력뿐이다. guard는 main()에서 이미 지났지만 Operator에도 필수로
|
|
1488
|
+
* 물린다: 이 factory가 아닌 다른 Surface가 Operator를 직접 만들 때도 같은 문을 지나게.
|
|
1489
|
+
*/
|
|
1490
|
+
async function runProceed(values, store, root, resolved) {
|
|
1491
|
+
const scm = await scmFor(resolved);
|
|
1492
|
+
const sessions = new SessionRuntime(store, resolved?.resolved.policy ?? null, {
|
|
1493
|
+
...(scm ? { scm } : {}),
|
|
1494
|
+
canonicalSources: (resolved?.canonicalSources ?? []).map((sourceId) => ({ sourceId })),
|
|
1495
|
+
...(resolved?.ownership ? { ownership: resolved.ownership } : {}),
|
|
1496
|
+
});
|
|
1497
|
+
const operator = new Operator({
|
|
1498
|
+
store,
|
|
1499
|
+
sessions,
|
|
1500
|
+
// 막힌 node만 보고 판단한다 — checkpoint를 발행했다는 이유로 멈추지 않는다 (C-13 §3.1)
|
|
1501
|
+
escalations: escalationLedger(store),
|
|
1502
|
+
// main()의 checkBootstrap과 같은 원천(bootstrapGuard)이다 — 중복 판단이 아니라 같은 문
|
|
1503
|
+
guard: async () => {
|
|
1504
|
+
const outcome = await checkBootstrap(root);
|
|
1505
|
+
return outcome.code === 0 ? { ok: true } : { ok: false, detail: 'bootstrap guard 실패 — 위 출력 참조' };
|
|
1506
|
+
},
|
|
1507
|
+
});
|
|
1508
|
+
const outcome = await operator.proceed({
|
|
1509
|
+
...(values.session ? { sessionId: values.session } : {}),
|
|
1510
|
+
...(values.goal ? { goal: values.goal } : {}),
|
|
1511
|
+
});
|
|
1512
|
+
if (values.json) {
|
|
1513
|
+
console.log(JSON.stringify(outcome, null, 2));
|
|
1514
|
+
return outcome.kind.startsWith('BLOCKED') || outcome.kind === 'FAILED' ? 1 : 0;
|
|
1515
|
+
}
|
|
1516
|
+
switch (outcome.kind) {
|
|
1517
|
+
case 'STARTED':
|
|
1518
|
+
case 'RESUMED':
|
|
1519
|
+
case 'CONTINUE_ACTIVE': {
|
|
1520
|
+
const verb = outcome.kind === 'STARTED' ? '시작' : outcome.kind === 'RESUMED' ? '재개' : '계속';
|
|
1521
|
+
console.log(`${outcome.contract.id} ${verb} — ${outcome.contract.goal}`);
|
|
1522
|
+
// 이어가는 경우에는 계약 복창보다 "지금 어떻게 되고 있는가"가 먼저다.
|
|
1523
|
+
// 재개(RESUMED)는 아래 checkpoint가 그 역할을 하므로 중복해서 말하지 않는다.
|
|
1524
|
+
if (outcome.kind === 'CONTINUE_ACTIVE') {
|
|
1525
|
+
const progress = await progressService(store).get(outcome.contract.id);
|
|
1526
|
+
const liveness = await livenessOf(store, outcome.contract.id);
|
|
1527
|
+
// 열린 상신은 신고와 무관하게 "지금 판단이 필요한 것"이다 — 화면이 그것을 먼저 말한다
|
|
1528
|
+
const rendered = renderProgress({
|
|
1529
|
+
session: outcome.contract,
|
|
1530
|
+
progress,
|
|
1531
|
+
...(liveness ? { liveness } : {}),
|
|
1532
|
+
...(outcome.awaiting && outcome.awaiting.length > 0 ? { awaiting: outcome.awaiting } : {}),
|
|
1533
|
+
});
|
|
1534
|
+
console.log(`\n${rendered.body.join('\n\n')}`);
|
|
1535
|
+
console.log(`\n> detail: ${rendered.detail}\n`);
|
|
1536
|
+
}
|
|
1537
|
+
// 막힌 것이 있으면 진행 화면보다 먼저 말한다 — 상신 2건이 열려 있는데
|
|
1538
|
+
// "판단 필요 항목 없음"이라고 하면 그 화면은 거짓말이다 (dogfood에서 잡힘).
|
|
1539
|
+
if (outcome.gate) {
|
|
1540
|
+
for (const line of executionLine(outcome.gate, 'Current state'))
|
|
1541
|
+
console.log(line);
|
|
1542
|
+
}
|
|
1543
|
+
// checkpoint는 중단 시점의 승계 정보다. 재개할 때만 지금 상황이며,
|
|
1544
|
+
// 이미 돌고 있는 세션(CONTINUE_ACTIVE)에 남아 있는 것은 지난 이야기다 (B-17).
|
|
1545
|
+
if (outcome.checkpoint && outcome.kind !== 'CONTINUE_ACTIVE') {
|
|
1546
|
+
console.log(`Resume at: ${outcome.checkpoint.position}`);
|
|
1547
|
+
console.log(`Next action: ${outcome.checkpoint.nextAction}`);
|
|
1548
|
+
}
|
|
1549
|
+
if (outcome.doneCriteria.length > 0) {
|
|
1550
|
+
console.log('Done criteria:');
|
|
1551
|
+
for (const criterion of outcome.doneCriteria)
|
|
1552
|
+
console.log(` - ${criterion}`);
|
|
1553
|
+
}
|
|
1554
|
+
if (outcome.contract.writeBoundary.length > 0) {
|
|
1555
|
+
console.log(`Write boundary: ${outcome.contract.writeBoundary.join(', ')}`);
|
|
1556
|
+
}
|
|
1557
|
+
return 0;
|
|
1558
|
+
}
|
|
1559
|
+
case 'NEEDS_SELECTION':
|
|
1560
|
+
console.log(`There are ${outcome.candidates.length} runnable sessions. Name the one you mean (--session):`);
|
|
1561
|
+
for (const c of outcome.candidates) {
|
|
1562
|
+
console.log(` ${c.id} ${c.status.padEnd(7)} ${c.wouldDo.padEnd(8)} ${c.goal}`);
|
|
1563
|
+
}
|
|
1564
|
+
return 1;
|
|
1565
|
+
case 'PROPOSE_CONTRACT':
|
|
1566
|
+
console.log('No runnable session. If a new contract is needed, the Controller issues it:');
|
|
1567
|
+
console.log(` asc session issue S-<date>-<n> --role ${outcome.draft.role} --goal "${outcome.draft.goal || '<goal>'}"`);
|
|
1568
|
+
return 1;
|
|
1569
|
+
case 'HELD':
|
|
1570
|
+
// 실패가 아니다 — 사람이 결정할 때까지 기다리는 것이고 세션은 그대로 있다
|
|
1571
|
+
console.log(`Held: ${outcome.detail}`);
|
|
1572
|
+
for (const line of executionLine(outcome.verdict, 'Current state'))
|
|
1573
|
+
console.log(line);
|
|
1574
|
+
console.log(`Escalations: ${outcome.escalations.join(', ')} — see them with \`asc escalate list\``);
|
|
1575
|
+
return 0;
|
|
1576
|
+
case 'BLOCKED_CONFIG':
|
|
1577
|
+
console.error(`Configuration check failed: ${outcome.detail}`);
|
|
1578
|
+
return 1;
|
|
1579
|
+
case 'BLOCKED_CANONICAL':
|
|
1580
|
+
console.error(`Canonical check failed: ${outcome.detail}`);
|
|
1581
|
+
for (const drift of outcome.drifts ?? []) {
|
|
1582
|
+
console.error(` ${drift.sourceId}: ${drift.recorded} → ${drift.current}`);
|
|
1583
|
+
}
|
|
1584
|
+
return 1;
|
|
1585
|
+
case 'FAILED':
|
|
1586
|
+
console.error(`Cannot proceed (${outcome.reason}): ${outcome.detail}`);
|
|
1587
|
+
return 1;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
/** 세션 lifecycle. 계약을 발급하고, 중단·재개하고, Handoff까지 남긴다 (OM §6.2). */
|
|
1591
|
+
/**
|
|
1592
|
+
* 이 세션이 걸린 결정마다 실제 결정권자. 세션 계약이 먼저고, 없으면 Profile 지도에서 푼다.
|
|
1593
|
+
* 풀리지 않은 것은 넣지 않는다 — 발급에서 이미 막혔거나, 막지 않기로 한 경로다.
|
|
1594
|
+
*/
|
|
1595
|
+
function effectiveAuthority(session, ownership) {
|
|
1596
|
+
const out = {};
|
|
1597
|
+
for (const domain of session.decisionDomains) {
|
|
1598
|
+
const explicit = session.decisionAuthority[domain];
|
|
1599
|
+
if (explicit) {
|
|
1600
|
+
out[domain] = explicit;
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
const found = lookupAuthority(ownership, domain);
|
|
1604
|
+
if (found.kind === 'RESOLVED')
|
|
1605
|
+
out[domain] = found.role;
|
|
1606
|
+
}
|
|
1607
|
+
return out;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* `--authority api-contract=backend` 를 map으로. 형식이 틀리면 조용히 버리지 않는다 —
|
|
1611
|
+
* 결정권자를 정했다고 믿는 사이 아무것도 정해지지 않는 것이 가장 나쁜 결과다.
|
|
1612
|
+
*/
|
|
1613
|
+
function parseAuthority(pairs) {
|
|
1614
|
+
const map = {};
|
|
1615
|
+
for (const pair of pairs ?? []) {
|
|
1616
|
+
const at = pair.indexOf('=');
|
|
1617
|
+
if (at <= 0 || at === pair.length - 1) {
|
|
1618
|
+
return { ok: false, detail: `--authority 는 <domain>=<role> 형식이다: '${pair}'` };
|
|
1619
|
+
}
|
|
1620
|
+
map[pair.slice(0, at)] = pair.slice(at + 1);
|
|
1621
|
+
}
|
|
1622
|
+
return { ok: true, map };
|
|
1623
|
+
}
|
|
1624
|
+
async function runSession(command, target, values, store, resolved) {
|
|
1625
|
+
// Profile이 정한 Role 범위와 금지가 여기까지 와야 실제로 강제된다.
|
|
1626
|
+
// 정책 없이 만들면 붙어 있는 프로젝트에서도 계약 검사가 통째로 비어버린다.
|
|
1627
|
+
const scm = await scmFor(resolved);
|
|
1628
|
+
const runtime = new SessionRuntime(store, resolved?.resolved.policy ?? null, {
|
|
1629
|
+
...(scm ? { scm } : {}),
|
|
1630
|
+
canonicalSources: (resolved?.canonicalSources ?? []).map((sourceId) => ({ sourceId })),
|
|
1631
|
+
...(resolved?.ownership ? { ownership: resolved.ownership } : {}),
|
|
1632
|
+
// checkpoint·handoff 쓰기에도 owner 검사를 건다 (C-10 §2.3). Progress만 검사하던
|
|
1633
|
+
// 비대칭을 닫는다 — binding이 없는 세션은 지금처럼 그냥 통과한다.
|
|
1634
|
+
bindings: claudeBindings(store),
|
|
1635
|
+
});
|
|
1636
|
+
if (command === 'list') {
|
|
1637
|
+
const sessions = await store.list('session');
|
|
1638
|
+
if (sessions.length === 0) {
|
|
1639
|
+
console.log('No sessions');
|
|
1640
|
+
return 0;
|
|
1641
|
+
}
|
|
1642
|
+
for (const session of sessions) {
|
|
1643
|
+
console.log(`${session.id} ${session.status.padEnd(7)} ${session.role.padEnd(12)} ${session.goal}`);
|
|
1644
|
+
}
|
|
1645
|
+
return 0;
|
|
1646
|
+
}
|
|
1647
|
+
if (!target) {
|
|
1648
|
+
console.error(`Usage: asc session ${command ?? '<command>'} <SESSION_ID>`);
|
|
1649
|
+
return 2;
|
|
1650
|
+
}
|
|
1651
|
+
const at = new Date().toISOString();
|
|
1652
|
+
switch (command) {
|
|
1653
|
+
case 'issue': {
|
|
1654
|
+
const role = SessionRole.safeParse(values.role);
|
|
1655
|
+
if (!role.success || !values.goal) {
|
|
1656
|
+
console.error('--role and --goal are required (role: planner|researcher|implementer|verifier)');
|
|
1657
|
+
return 2;
|
|
1658
|
+
}
|
|
1659
|
+
const authority = parseAuthority(values.authority);
|
|
1660
|
+
if (!authority.ok) {
|
|
1661
|
+
console.error(authority.detail);
|
|
1662
|
+
return 2;
|
|
1663
|
+
}
|
|
1664
|
+
const issued = await runtime.issue({
|
|
1665
|
+
id: target,
|
|
1666
|
+
role: role.data,
|
|
1667
|
+
goal: values.goal,
|
|
1668
|
+
...(values.criteria ? { doneCriteria: values.criteria } : {}),
|
|
1669
|
+
...(values.block ? { blockId: values.block } : {}),
|
|
1670
|
+
...(values.boundary ? { writeBoundary: values.boundary } : {}),
|
|
1671
|
+
...(values.exception ? { policyExceptions: values.exception } : {}),
|
|
1672
|
+
...(values.owner ? { owner: values.owner } : {}),
|
|
1673
|
+
...(values.domain ? { decisionDomains: values.domain } : {}),
|
|
1674
|
+
...(Object.keys(authority.map).length > 0 ? { decisionAuthority: authority.map } : {}),
|
|
1675
|
+
...(values.dependency ? { dependencies: values.dependency } : {}),
|
|
1676
|
+
});
|
|
1677
|
+
if (!issued.ok) {
|
|
1678
|
+
console.error('Could not issue the session:');
|
|
1679
|
+
for (const failure of issued.failures)
|
|
1680
|
+
console.error(` - ${failure.kind}: ${failure.detail}`);
|
|
1681
|
+
return 1;
|
|
1682
|
+
}
|
|
1683
|
+
// 위임을 **선언**으로 남긴다 (C-10 §1.1). 상태 전이는 일으키지 않는다 —
|
|
1684
|
+
// 누가 맡겼는지는 세션 상태가 아니라 별도 증거다.
|
|
1685
|
+
const audit = auditLedger(store);
|
|
1686
|
+
const delegated = await audit.delegate({
|
|
1687
|
+
...(values.parent ? { parentSessionId: values.parent } : {}),
|
|
1688
|
+
childSessionId: issued.session.id,
|
|
1689
|
+
role: issued.session.role,
|
|
1690
|
+
goal: issued.session.goal,
|
|
1691
|
+
scope: issued.session.writeBoundary,
|
|
1692
|
+
doneCriteria: issued.session.doneCriteria,
|
|
1693
|
+
issuedBy: values['issued-by'] ?? values.as ?? issued.session.owner ?? '(미상)',
|
|
1694
|
+
issuedAt: at,
|
|
1695
|
+
...(values.parent ? { expectedReturnTo: values.parent } : {}),
|
|
1696
|
+
});
|
|
1697
|
+
console.log(`${issued.session.id} READY — ${issued.session.goal}`);
|
|
1698
|
+
if (delegated.ok)
|
|
1699
|
+
console.log(delegationLine(delegated.record, issued.session.id));
|
|
1700
|
+
return 0;
|
|
1701
|
+
}
|
|
1702
|
+
case 'decision': {
|
|
1703
|
+
const selected = values.selected;
|
|
1704
|
+
const decisionClass = values.class;
|
|
1705
|
+
if (!selected || !decisionClass) {
|
|
1706
|
+
console.error('사용법: asc session decision <S-ID> --class <c> --selected <t> --why <t>... --evidence <ref>...');
|
|
1707
|
+
return 2;
|
|
1708
|
+
}
|
|
1709
|
+
const evidence = values.evidence ?? [];
|
|
1710
|
+
const why = values.why ?? [];
|
|
1711
|
+
if (evidence.length === 0 || why.length === 0) {
|
|
1712
|
+
// 근거와 "왜 경계가 아니었는가"가 없으면 자율 판단이 아니라 그냥 안 물어본 것이다
|
|
1713
|
+
console.error('At least one --evidence and one --why are required (C-13 §4).');
|
|
1714
|
+
return 2;
|
|
1715
|
+
}
|
|
1716
|
+
const audit = auditLedger(store);
|
|
1717
|
+
const recorded = await audit.decide({
|
|
1718
|
+
sessionId: target,
|
|
1719
|
+
actor: values.as ?? values.principal ?? '(미상)',
|
|
1720
|
+
ownership: values.ownership ?? [],
|
|
1721
|
+
class: decisionClass,
|
|
1722
|
+
evidenceRefs: [evidence[0], ...evidence.slice(1)],
|
|
1723
|
+
selectedOption: selected,
|
|
1724
|
+
alternatives: values.alternative ?? [],
|
|
1725
|
+
whyNoApproval: [why[0], ...why.slice(1)],
|
|
1726
|
+
verification: values.verification ?? [],
|
|
1727
|
+
decidedAt: at,
|
|
1728
|
+
});
|
|
1729
|
+
for (const line of decisionLines([recorded.decision]))
|
|
1730
|
+
console.log(line);
|
|
1731
|
+
return 0;
|
|
1732
|
+
}
|
|
1733
|
+
case 'validate': {
|
|
1734
|
+
const result = values.result === 'PASS' || values.result === 'FAIL' ? values.result : null;
|
|
1735
|
+
if (!values.validator || !result) {
|
|
1736
|
+
console.error('Usage: asc session validate <target S-ID> --validator <validator S-ID> --result PASS|FAIL');
|
|
1737
|
+
return 2;
|
|
1738
|
+
}
|
|
1739
|
+
const audit = auditLedger(store);
|
|
1740
|
+
const targetSession = await store.get('session', target);
|
|
1741
|
+
const recorded = await audit.validate({
|
|
1742
|
+
validatorSessionId: values.validator,
|
|
1743
|
+
targetSessionId: target,
|
|
1744
|
+
result,
|
|
1745
|
+
...(values.finding ? { findings: values.finding } : {}),
|
|
1746
|
+
...(targetSession?.handoff ? { targetHandoffRef: targetSession.handoff.recordedAt } : {}),
|
|
1747
|
+
...(values.revision ? { targetRevision: values.revision } : {}),
|
|
1748
|
+
verifiedAt: at,
|
|
1749
|
+
});
|
|
1750
|
+
if (!recorded.ok) {
|
|
1751
|
+
console.error(recorded.detail);
|
|
1752
|
+
return 1;
|
|
1753
|
+
}
|
|
1754
|
+
for (const line of validationLines([recorded.record]))
|
|
1755
|
+
console.log(line);
|
|
1756
|
+
if (recorded.record.independence !== 'INDEPENDENT') {
|
|
1757
|
+
console.log('This record does not count as independent verification — see the reason above.');
|
|
1758
|
+
}
|
|
1759
|
+
return 0;
|
|
1760
|
+
}
|
|
1761
|
+
case 'report': {
|
|
1762
|
+
const audit = auditLedger(store);
|
|
1763
|
+
const found = (await store.get('session', target)) ?? null;
|
|
1764
|
+
if (!found) {
|
|
1765
|
+
// 회수된 세션은 archive로 갔다. 증거는 남아 있으므로 audit으로 보라고 말한다.
|
|
1766
|
+
console.error(`${target} is not in the current list — if it was collected, try asc session audit ${target}`);
|
|
1767
|
+
return 1;
|
|
1768
|
+
}
|
|
1769
|
+
const executions = await audit.executionsOf(target);
|
|
1770
|
+
const validations = await audit.validationsOf(target);
|
|
1771
|
+
const report = buildFinalReport({
|
|
1772
|
+
session: found,
|
|
1773
|
+
executions,
|
|
1774
|
+
validations,
|
|
1775
|
+
decisions: await audit.decisionsOf(target),
|
|
1776
|
+
escalations: (await escalationLedger(store).pending()).filter((e) => e.sessionId === target),
|
|
1777
|
+
reclaim: await audit.reclaimOf(target),
|
|
1778
|
+
derived: deriveExecutionState({
|
|
1779
|
+
...(found.handoff ? { metCriteria: found.handoff.done } : {}),
|
|
1780
|
+
doneCriteria: found.doneCriteria,
|
|
1781
|
+
...(validations.length > 0
|
|
1782
|
+
? { verificationPassed: validations.every((v) => v.result === 'PASS') }
|
|
1783
|
+
: {}),
|
|
1784
|
+
...(found.handoff?.unresolved.length ? { waitingOn: found.handoff.unresolved } : {}),
|
|
1785
|
+
}),
|
|
1786
|
+
});
|
|
1787
|
+
if (values.json) {
|
|
1788
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1789
|
+
return 0;
|
|
1790
|
+
}
|
|
1791
|
+
for (const line of renderFinalReport(report))
|
|
1792
|
+
console.log(line);
|
|
1793
|
+
return 0;
|
|
1794
|
+
}
|
|
1795
|
+
case 'audit': {
|
|
1796
|
+
const audit = auditLedger(store);
|
|
1797
|
+
const session = (await store.get('session', target)) ?? null;
|
|
1798
|
+
const bindings = claudeBindings(store);
|
|
1799
|
+
console.log(delegationLine(await audit.delegationOf(target), target));
|
|
1800
|
+
if (session) {
|
|
1801
|
+
console.log(`Status: ${session.status} · role: ${session.role}`);
|
|
1802
|
+
// 검증자가 대조할 acceptance가 여기 없으면 세션 파일을 직접 열어야 한다 (dogfood에서 잡힘)
|
|
1803
|
+
if (session.doneCriteria.length > 0) {
|
|
1804
|
+
console.log('Done criteria:');
|
|
1805
|
+
const done = new Set(session.handoff?.done ?? []);
|
|
1806
|
+
for (const item of session.doneCriteria)
|
|
1807
|
+
console.log(` ${done.has(item) ? '[x]' : '[ ]'} ${item}`);
|
|
1808
|
+
}
|
|
1809
|
+
if (session.writeBoundary.length > 0)
|
|
1810
|
+
console.log(`Write boundary: ${session.writeBoundary.join(', ')}`);
|
|
1811
|
+
}
|
|
1812
|
+
if (session?.checkpoint) {
|
|
1813
|
+
const c = session.checkpoint;
|
|
1814
|
+
console.log(`Latest checkpoint: ${c.position}${c.currentJudgment ? ` — ${c.currentJudgment}` : ''}`);
|
|
1815
|
+
if (c.blockers.length > 0)
|
|
1816
|
+
console.log(` blocked: ${c.blockers.join(' · ')}`);
|
|
1817
|
+
}
|
|
1818
|
+
if (session?.handoff) {
|
|
1819
|
+
console.log(`Handoff: ${session.handoff.next}`);
|
|
1820
|
+
// 무엇을 고쳤는지가 비어 있으면 검증자가 저장소를 뒤져 알아내야 한다
|
|
1821
|
+
console.log(session.handoff.changed.length > 0
|
|
1822
|
+
? ` changed: ${session.handoff.changed.join(', ')}`
|
|
1823
|
+
: ' changed: (nothing recorded — the ASC record cannot say what changed)');
|
|
1824
|
+
console.log(` self-check (not independent verification): ${session.handoff.verified}`);
|
|
1825
|
+
}
|
|
1826
|
+
console.log('Execution:');
|
|
1827
|
+
for (const line of executionLines(await audit.executionsOf(target)))
|
|
1828
|
+
console.log(line);
|
|
1829
|
+
console.log('Decided without approval:');
|
|
1830
|
+
for (const line of decisionLines(await audit.decisionsOf(target)))
|
|
1831
|
+
console.log(line);
|
|
1832
|
+
const openEscalations = (await escalationLedger(store).all()).filter((e) => e.sessionId === target);
|
|
1833
|
+
console.log('Escalated:');
|
|
1834
|
+
if (openEscalations.length === 0)
|
|
1835
|
+
console.log(' no escalations');
|
|
1836
|
+
for (const record of openEscalations) {
|
|
1837
|
+
console.log(` ${record.escalationId} [${record.predicates.join(', ')}] ${record.question} → ${record.requestId}`);
|
|
1838
|
+
console.log(` blocked: ${record.blockedNodes.join(', ')}`);
|
|
1839
|
+
// 검증자는 "어느 경계라서 못 했는가"를 여기서 봐야 한다 — 세션 파일을 직접 열게 하지 않는다
|
|
1840
|
+
if (record.blockedScope.length > 0)
|
|
1841
|
+
console.log(` boundary: ${record.blockedScope.join(', ')}`);
|
|
1842
|
+
if (record.stillRunnableNodes.length > 0)
|
|
1843
|
+
console.log(` still ran: ${record.stillRunnableNodes.join(', ')}`);
|
|
1844
|
+
}
|
|
1845
|
+
console.log('Verification:');
|
|
1846
|
+
for (const line of validationLines(await audit.validationsOf(target)))
|
|
1847
|
+
console.log(line);
|
|
1848
|
+
console.log(reclaimLine(await audit.reclaimOf(target), target));
|
|
1849
|
+
const owner = await bindings.get(target);
|
|
1850
|
+
console.log(`Currently held by: ${owner ? owner.physicalSessionId : 'nobody (released, or never picked up)'}`);
|
|
1851
|
+
const children = await audit.delegationsFrom(target);
|
|
1852
|
+
if (children.length > 0) {
|
|
1853
|
+
console.log('Delegated by this session:');
|
|
1854
|
+
for (const child of children)
|
|
1855
|
+
console.log(` ${delegationLine(child, child.childSessionId)}`);
|
|
1856
|
+
}
|
|
1857
|
+
return 0;
|
|
1858
|
+
}
|
|
1859
|
+
case 'start':
|
|
1860
|
+
return reportStart(await runtime.start(target), `${target} ACTIVE`);
|
|
1861
|
+
case 'pause': {
|
|
1862
|
+
if (!values.position || !values.next) {
|
|
1863
|
+
console.error('--position and --next are required. Whoever picks this up reads them.');
|
|
1864
|
+
return 2;
|
|
1865
|
+
}
|
|
1866
|
+
const checkpoint = Checkpoint.parse({
|
|
1867
|
+
position: values.position,
|
|
1868
|
+
completedTasks: values.done ?? [],
|
|
1869
|
+
nextAction: values.next,
|
|
1870
|
+
uncommittedChanges: values.changed ?? [],
|
|
1871
|
+
// 의미 있는 전환을 남긴다 (C-10 §2.1) — 지금 무엇이 사실이라고 보는가, 무엇이
|
|
1872
|
+
// 막고 있는가, 그 판단의 근거는 무엇인가.
|
|
1873
|
+
...(values.judgment ? { currentJudgment: values.judgment } : {}),
|
|
1874
|
+
...(values.blocker ? { blockers: values.blocker } : {}),
|
|
1875
|
+
...(values.risk ? { risks: values.risk } : {}),
|
|
1876
|
+
...(values.evidence ? { evidenceRefs: values.evidence } : {}),
|
|
1877
|
+
...(values.physical ? { writtenBy: values.physical } : {}),
|
|
1878
|
+
recordedAt: at,
|
|
1879
|
+
});
|
|
1880
|
+
return report(await runtime.pause(target, checkpoint, values.physical), `${target} PAUSED — 다음: ${checkpoint.nextAction}`);
|
|
1881
|
+
}
|
|
1882
|
+
case 'resume': {
|
|
1883
|
+
const session = await runtime.get(target);
|
|
1884
|
+
if (session?.checkpoint) {
|
|
1885
|
+
console.log(`Resume at: ${session.checkpoint.position}`);
|
|
1886
|
+
console.log(`Next action: ${session.checkpoint.nextAction}`);
|
|
1887
|
+
if (session.checkpoint.uncommittedChanges.length > 0) {
|
|
1888
|
+
console.log(`Uncommitted: ${session.checkpoint.uncommittedChanges.join(', ')}`);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
return reportStart(await runtime.resume(target), `${target} ACTIVE`);
|
|
1892
|
+
}
|
|
1893
|
+
case 'done': {
|
|
1894
|
+
if (!values.verified || !values.next) {
|
|
1895
|
+
console.error('--verified and --next are required. They record what was checked, and how.');
|
|
1896
|
+
return 2;
|
|
1897
|
+
}
|
|
1898
|
+
const handoff = Handoff.parse({
|
|
1899
|
+
done: values.done ?? [],
|
|
1900
|
+
changed: values.changed ?? [],
|
|
1901
|
+
verified: values.verified,
|
|
1902
|
+
unresolved: values.unresolved ?? [],
|
|
1903
|
+
next: values.next,
|
|
1904
|
+
recordedAt: at,
|
|
1905
|
+
});
|
|
1906
|
+
const outcome = await runtime.complete(target, handoff, values.physical);
|
|
1907
|
+
if (!outcome.ok)
|
|
1908
|
+
return report(outcome, '');
|
|
1909
|
+
console.log(`${target} DONE — handoff written`);
|
|
1910
|
+
console.log('Updating state and blocks is the Controller\'s: `asc controller collect`');
|
|
1911
|
+
return 0;
|
|
1912
|
+
}
|
|
1913
|
+
default:
|
|
1914
|
+
console.error(`Unknown session command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
1915
|
+
return 2;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
/** 정본이 움직였으면 무엇이 어떻게 달라졌는지 보이고 멈춘다. */
|
|
1919
|
+
function reportStart(outcome, success) {
|
|
1920
|
+
if (!outcome.ok && outcome.reason === 'CANONICAL_UNAVAILABLE') {
|
|
1921
|
+
console.error(`The canonical source cannot be read: ${outcome.detail}`);
|
|
1922
|
+
console.error('Nothing starts while it is unknown what it stands on.');
|
|
1923
|
+
return 1;
|
|
1924
|
+
}
|
|
1925
|
+
if (!outcome.ok && outcome.reason === 'CANONICAL_DRIFT') {
|
|
1926
|
+
console.error('The canonical source differs from when this was issued — the ground under the contract moved:');
|
|
1927
|
+
for (const drift of outcome.drifts ?? []) {
|
|
1928
|
+
console.error(` ${drift.sourceId}: ${drift.recorded} → ${drift.current}`);
|
|
1929
|
+
}
|
|
1930
|
+
console.error('\nReview the changes, and hand the contract back to the Controller if they affect it.');
|
|
1931
|
+
return 1;
|
|
1932
|
+
}
|
|
1933
|
+
return report(outcome, success);
|
|
1934
|
+
}
|
|
1935
|
+
function report(outcome, success) {
|
|
1936
|
+
if (outcome.ok) {
|
|
1937
|
+
console.log(success);
|
|
1938
|
+
return 0;
|
|
1939
|
+
}
|
|
1940
|
+
if (outcome.reason === 'REJECTED')
|
|
1941
|
+
console.error(outcome.failure.message);
|
|
1942
|
+
// 소유권 거부는 이유를 그대로 보여준다 — "실패: NOT_OWNER" 만으로는 누가 owner인지 모른다
|
|
1943
|
+
else
|
|
1944
|
+
console.error(outcome.detail ?? `failed: ${outcome.reason}`);
|
|
1945
|
+
return 1;
|
|
1946
|
+
}
|
|
1947
|
+
/** 끝난 세션을 거둬 Controller 상태를 다시 쓴다. 세션은 이 문서를 직접 고치지 않는다. */
|
|
1948
|
+
async function runController(command, values, store, resolved) {
|
|
1949
|
+
if (command !== 'collect') {
|
|
1950
|
+
console.error(`Unknown controller command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
1951
|
+
return 2;
|
|
1952
|
+
}
|
|
1953
|
+
// Core는 Profile을 모른다 — 선언을 꺼내 넘기는 것은 Surface의 몫이다
|
|
1954
|
+
const checklist = resolved?.resolved.policy.lists[CLOSURE_CHECKLIST] ?? [];
|
|
1955
|
+
const reclaimedBy = values.as ?? Object.keys(resolved?.controllerIdentities ?? {})[0];
|
|
1956
|
+
if (!reclaimedBy) {
|
|
1957
|
+
console.error('누가 거두는지 알 수 없다 — --as <주체> 로 지정하거나 override.json 의 controller.identities 를 채워라.');
|
|
1958
|
+
return 2;
|
|
1959
|
+
}
|
|
1960
|
+
const outcome = await collectSessions(store, new Date().toISOString(), {
|
|
1961
|
+
closureChecklist: checklist,
|
|
1962
|
+
closureLedger: closureLedger(store),
|
|
1963
|
+
queryLedger: queryLedger(store, resolved),
|
|
1964
|
+
escalationLedger: escalationLedger(store),
|
|
1965
|
+
auditLedger: auditLedger(store),
|
|
1966
|
+
// 누가 거뒀는지 모르면 History에 'controller' 라는 익명이 남는다 (C-10 §2.4)
|
|
1967
|
+
...(reclaimedBy ? { reclaimedBy } : {}),
|
|
1968
|
+
});
|
|
1969
|
+
console.log(renderCollect(outcome, await store.list('session')));
|
|
1970
|
+
// 거둔 세션의 live 진행 표시는 여기서 정리한다 — 종결 보고(terminal)는 남는다
|
|
1971
|
+
const cleared = await progressService(store).collect(outcome.collected);
|
|
1972
|
+
if (cleared.length > 0)
|
|
1973
|
+
console.log(`\nCleared progress markers: ${cleared.join(', ')}`);
|
|
1974
|
+
return 0;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Bounded Query (B-25) — 다른 파트에 묻되 답할 수 있는 형태로만 묻는다.
|
|
1978
|
+
* 답 하나로 권한이 생기지 않는다. 여기서 나가는 것은 정보뿐이다 (C-04 §3.4).
|
|
1979
|
+
*/
|
|
1980
|
+
async function runQuery(command, target, values, store, resolved) {
|
|
1981
|
+
const ledger = queryLedger(store, resolved);
|
|
1982
|
+
if (command === 'list') {
|
|
1983
|
+
const entries = await ledger.list();
|
|
1984
|
+
const violations = await ledger.violations();
|
|
1985
|
+
if (values.json) {
|
|
1986
|
+
console.log(JSON.stringify({ entries, violations }, null, 2));
|
|
1987
|
+
return 0;
|
|
1988
|
+
}
|
|
1989
|
+
if (entries.length === 0 && violations.length === 0) {
|
|
1990
|
+
console.log('No queries');
|
|
1991
|
+
return 0;
|
|
1992
|
+
}
|
|
1993
|
+
for (const { query, answer } of entries) {
|
|
1994
|
+
const state = answer ? `${answer.kind} — ${answer.byRole}` : '답 대기';
|
|
1995
|
+
console.log(`${query.id} ${query.requestedAuthority.padEnd(20)} ${state}`);
|
|
1996
|
+
console.log(` ${query.question}`);
|
|
1997
|
+
if (answer)
|
|
1998
|
+
console.log(` → ${answer.body}`);
|
|
1999
|
+
else if (query.proposedDefault)
|
|
2000
|
+
console.log(` (with no answer: ${query.proposedDefault})`);
|
|
2001
|
+
}
|
|
2002
|
+
for (const v of violations)
|
|
2003
|
+
console.log(`${v.attemptedId} ${v.kind} — ${v.detail}`);
|
|
2004
|
+
return 0;
|
|
2005
|
+
}
|
|
2006
|
+
if (!target) {
|
|
2007
|
+
console.error(`Usage: asc query ${command ?? '<command>'} <X-ID>`);
|
|
2008
|
+
return 2;
|
|
2009
|
+
}
|
|
2010
|
+
if (command === 'open') {
|
|
2011
|
+
const domain = values.domain?.[0];
|
|
2012
|
+
if (!values.session || !domain || !values.question) {
|
|
2013
|
+
console.error('--session, --domain and --question are required.');
|
|
2014
|
+
return 2;
|
|
2015
|
+
}
|
|
2016
|
+
const session = await store.get('session', values.session);
|
|
2017
|
+
if (!session) {
|
|
2018
|
+
console.error(`Session '${values.session}' was not found.`);
|
|
2019
|
+
return 1;
|
|
2020
|
+
}
|
|
2021
|
+
const expected = values['expect-response'];
|
|
2022
|
+
if (expected && expected !== 'DECIDE' && expected !== 'ANSWER') {
|
|
2023
|
+
console.error('--expect-response must be DECIDE or ANSWER.');
|
|
2024
|
+
return 2;
|
|
2025
|
+
}
|
|
2026
|
+
const outcome = await ledger.open({
|
|
2027
|
+
id: target,
|
|
2028
|
+
ownerSessionId: session.id,
|
|
2029
|
+
...(session.owner ? { ownerRole: session.owner } : {}),
|
|
2030
|
+
requestedAuthority: domain,
|
|
2031
|
+
question: values.question,
|
|
2032
|
+
...(values.context ? { context: values.context } : {}),
|
|
2033
|
+
...(values.default ? { proposedDefault: values.default } : {}),
|
|
2034
|
+
...(values.blocking ? { blockingScope: values.blocking } : {}),
|
|
2035
|
+
...(expected ? { expectedResponse: expected } : {}),
|
|
2036
|
+
...(values['in-reply-to'] ? { inReplyTo: values['in-reply-to'] } : {}),
|
|
2037
|
+
});
|
|
2038
|
+
if (!outcome.ok) {
|
|
2039
|
+
console.error(`Could not open the query — ${outcome.reason}`);
|
|
2040
|
+
console.error(` ${outcome.detail}`);
|
|
2041
|
+
return 1;
|
|
2042
|
+
}
|
|
2043
|
+
console.log(`${outcome.query.id} opened — waiting for an answer on '${outcome.query.requestedAuthority}'.`);
|
|
2044
|
+
console.log(`${outcome.query.ownerSessionId} asked, and an answer does not transfer ownership of the work.`);
|
|
2045
|
+
return 0;
|
|
2046
|
+
}
|
|
2047
|
+
if (command === 'answer') {
|
|
2048
|
+
const kind = values.kind;
|
|
2049
|
+
if (kind !== 'DECIDE' && kind !== 'ANSWER' && kind !== 'ESCALATE') {
|
|
2050
|
+
console.error('--kind must be one of DECIDE|ANSWER|ESCALATE.');
|
|
2051
|
+
return 2;
|
|
2052
|
+
}
|
|
2053
|
+
if (!values.by || !values.body) {
|
|
2054
|
+
console.error('--by <role> and --body <text> are required.');
|
|
2055
|
+
return 2;
|
|
2056
|
+
}
|
|
2057
|
+
if (kind === 'ESCALATE' && !values.to) {
|
|
2058
|
+
console.error('ESCALATE requires --to <authority> — it goes to a person with authority, not to another agent.');
|
|
2059
|
+
return 2;
|
|
2060
|
+
}
|
|
2061
|
+
const outcome = await ledger.answer(target, {
|
|
2062
|
+
kind,
|
|
2063
|
+
byRole: values.by,
|
|
2064
|
+
body: values.body,
|
|
2065
|
+
...(values.to ? { escalateTo: values.to } : {}),
|
|
2066
|
+
});
|
|
2067
|
+
if (!outcome.ok) {
|
|
2068
|
+
console.error(`Could not answer — ${outcome.reason}`);
|
|
2069
|
+
console.error(` ${outcome.detail}`);
|
|
2070
|
+
return 1;
|
|
2071
|
+
}
|
|
2072
|
+
console.log(`${target} ${outcome.answer.kind} — ${outcome.answer.byRole}`);
|
|
2073
|
+
console.log(`Control returns to ${outcome.query.ownerSessionId}. This answer creates no approval, authority or scope.`);
|
|
2074
|
+
return 0;
|
|
2075
|
+
}
|
|
2076
|
+
console.error(`Unknown query command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
2077
|
+
return 2;
|
|
2078
|
+
}
|
|
2079
|
+
/** Profile이 마무리 항목을 선언하는 관례 키 (B-20). */
|
|
2080
|
+
const CLOSURE_CHECKLIST = 'closureChecklist';
|
|
2081
|
+
/** Orchestration Audit (C-10). 회수 후에도 남는 기록이라 Progress와 다른 자리에 산다. */
|
|
2082
|
+
const auditLedger = (store) => new AuditLedger(store.scope('audit'));
|
|
2083
|
+
const closureLedger = (store) => new ClosureLedger(store.scope('closure'));
|
|
2084
|
+
/** Bounded Query (B-25). 결정권 판정에 Profile 책임 지도가 필요하다. */
|
|
2085
|
+
const queryLedger = (store, resolved) => new QueryLedger(store.scope('query'), resolved?.ownership);
|
|
2086
|
+
/**
|
|
2087
|
+
* 프로젝트 마무리 의무 (B-20). 확인은 Controller가 항목 id로 명시한다 —
|
|
2088
|
+
* Handoff 텍스트를 읽어 추론하지 않는다.
|
|
2089
|
+
*/
|
|
2090
|
+
async function runClosure(command, target, values, store) {
|
|
2091
|
+
const ledger = closureLedger(store);
|
|
2092
|
+
if (command === 'list') {
|
|
2093
|
+
const records = target ? [await ledger.get(target)].filter((r) => r !== null) : await ledger.list();
|
|
2094
|
+
if (records.length === 0) {
|
|
2095
|
+
console.log(target ? `${target} has no closure record.` : 'No closure records.');
|
|
2096
|
+
return target ? 1 : 0;
|
|
2097
|
+
}
|
|
2098
|
+
if (values.json) {
|
|
2099
|
+
console.log(JSON.stringify(records, null, 2));
|
|
2100
|
+
return 0;
|
|
2101
|
+
}
|
|
2102
|
+
for (const record of records) {
|
|
2103
|
+
const pending = record.declared.filter((item) => !record.confirmed.includes(item));
|
|
2104
|
+
const state = record.closedAt ? `닫힘 (${record.closedAt})` : `미확인 ${pending.length}건`;
|
|
2105
|
+
console.log(`${record.logicalSessionId} — ${state}`);
|
|
2106
|
+
for (const item of record.declared) {
|
|
2107
|
+
console.log(` ${record.confirmed.includes(item) ? '[x]' : '[ ]'} ${item}`);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
return 0;
|
|
2111
|
+
}
|
|
2112
|
+
if (command === 'confirm') {
|
|
2113
|
+
const items = values.item ?? [];
|
|
2114
|
+
if (!target || items.length === 0) {
|
|
2115
|
+
console.error('Usage: asc closure confirm <S-ID> --item <item id>...');
|
|
2116
|
+
return 2;
|
|
2117
|
+
}
|
|
2118
|
+
const outcome = await ledger.confirm(target, items);
|
|
2119
|
+
if (!outcome.ok) {
|
|
2120
|
+
console.error(`Could not confirm (${outcome.reason}): ${outcome.detail}`);
|
|
2121
|
+
if (outcome.reason === 'UNKNOWN_ITEM') {
|
|
2122
|
+
console.error(`Declared items: ${outcome.declared.join(', ')}`);
|
|
2123
|
+
}
|
|
2124
|
+
return 1;
|
|
2125
|
+
}
|
|
2126
|
+
const pending = outcome.record.declared.filter((i) => !outcome.record.confirmed.includes(i));
|
|
2127
|
+
console.log(outcome.newlyClosed
|
|
2128
|
+
? `${target} 마무리 완료 — 선언된 ${outcome.record.declared.length}건 전부 확인됐다.`
|
|
2129
|
+
: `${target} 확인 ${items.length}건 기록. 남은 항목: ${pending.join(', ')}`);
|
|
2130
|
+
return 0;
|
|
2131
|
+
}
|
|
2132
|
+
console.error(`Unknown closure command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
2133
|
+
return 2;
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* hook이 남긴 활동 신호. 진척이 아니라 "도구가 돌았다"까지이므로 Renderer에
|
|
2137
|
+
* 보조 정보로만 넘긴다 — 없으면 없는 대로 둔다 (B-18).
|
|
2138
|
+
*/
|
|
2139
|
+
async function livenessOf(store, logicalSessionId) {
|
|
2140
|
+
const beat = await readHeartbeat(store.scope(CLAUDE_SCOPE), logicalSessionId);
|
|
2141
|
+
if (!beat)
|
|
2142
|
+
return null;
|
|
2143
|
+
return { lastActivityAt: beat.lastActivityAt, ...(beat.lastTool ? { lastTool: beat.lastTool } : {}) };
|
|
2144
|
+
}
|
|
2145
|
+
/** Core는 provider를 모른다 — 소유권 판정에 쓸 binding은 Surface가 조립해 넘긴다. */
|
|
2146
|
+
function progressService(store) {
|
|
2147
|
+
return new ProgressService({
|
|
2148
|
+
scope: store.scope('progress'),
|
|
2149
|
+
bindings: claudeBindings(store),
|
|
2150
|
+
appendHistory: (entry) => store.appendHistory(entry),
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* 산출 경로를 넘기기 전에 맞춰 본다 (B-19). 판정과 제안까지만 — 범위를 넓히거나
|
|
2155
|
+
* 역할을 바꾸거나 세션을 발급하지 않는다. 그건 Controller의 결정이다.
|
|
2156
|
+
*/
|
|
2157
|
+
async function runPreflight(values, store, resolved) {
|
|
2158
|
+
const paths = values.path ?? [];
|
|
2159
|
+
if (paths.length === 0 || (!values.role && !values.session)) {
|
|
2160
|
+
console.error("Usage: asc preflight --path <path>... (--role <role> | --session <S-ID>)");
|
|
2161
|
+
return 2;
|
|
2162
|
+
}
|
|
2163
|
+
if (values.role && values.session) {
|
|
2164
|
+
// 둘을 겹쳐 받으면 어느 쪽으로 판정했는지 출력만 봐서는 알 수 없다
|
|
2165
|
+
console.error('Pass either --role or --session, not both — the maximum scope and a session contract are different standards.');
|
|
2166
|
+
return 2;
|
|
2167
|
+
}
|
|
2168
|
+
let target;
|
|
2169
|
+
if (values.session) {
|
|
2170
|
+
const session = await store.get('session', values.session);
|
|
2171
|
+
if (!session) {
|
|
2172
|
+
console.error(`Session '${values.session}' was not found.`);
|
|
2173
|
+
return 1;
|
|
2174
|
+
}
|
|
2175
|
+
target = {
|
|
2176
|
+
kind: 'session',
|
|
2177
|
+
sessionId: session.id,
|
|
2178
|
+
role: session.role,
|
|
2179
|
+
writeBoundary: session.writeBoundary,
|
|
2180
|
+
...(session.owner ? { owner: session.owner } : {}),
|
|
2181
|
+
...(session.decisionDomains.length > 0 ? { decisionDomains: session.decisionDomains } : {}),
|
|
2182
|
+
...(Object.keys(session.decisionAuthority).length > 0
|
|
2183
|
+
? { decisionAuthority: session.decisionAuthority }
|
|
2184
|
+
: {}),
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
else {
|
|
2188
|
+
const role = SessionRole.safeParse(values.role);
|
|
2189
|
+
if (!role.success) {
|
|
2190
|
+
console.error('--role must be one of planner|researcher|implementer|verifier');
|
|
2191
|
+
return 2;
|
|
2192
|
+
}
|
|
2193
|
+
const scopes = resolved?.resolved.policy.roleScopes;
|
|
2194
|
+
target = { kind: 'role', role: role.data, maxScope: scopes ? scopes[role.data] : undefined };
|
|
2195
|
+
}
|
|
2196
|
+
const result = preflight({
|
|
2197
|
+
paths,
|
|
2198
|
+
target,
|
|
2199
|
+
...(resolved ? { policy: resolved.resolved.policy } : {}),
|
|
2200
|
+
...(resolved?.ownership ? { ownership: resolved.ownership } : {}),
|
|
2201
|
+
});
|
|
2202
|
+
if (values.json) {
|
|
2203
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2204
|
+
return result.undecidable || result.mismatches.length > 0 || result.authorityGaps.length > 0 ? 1 : 0;
|
|
2205
|
+
}
|
|
2206
|
+
const against = result.target.kind === 'role'
|
|
2207
|
+
? `역할 '${result.target.role}' 의 최대 쓰기 범위`
|
|
2208
|
+
: `${result.target.sessionId} 의 쓰기 범위`;
|
|
2209
|
+
if (result.undecidable) {
|
|
2210
|
+
console.error(`Cannot compare: ${result.undecidable}`);
|
|
2211
|
+
return 1;
|
|
2212
|
+
}
|
|
2213
|
+
if (result.mismatches.length === 0 && result.authorityGaps.length === 0) {
|
|
2214
|
+
console.log(`All ${paths.length} output paths fall inside ${against}.`);
|
|
2215
|
+
if (result.target.kind === 'session' && result.target.decisionDomains?.length) {
|
|
2216
|
+
console.log(`All ${result.target.decisionDomains.length} decision domains have an owner.`);
|
|
2217
|
+
}
|
|
2218
|
+
return 0;
|
|
2219
|
+
}
|
|
2220
|
+
if (result.mismatches.length > 0) {
|
|
2221
|
+
console.error(`Some output paths fall outside ${against}:`);
|
|
2222
|
+
for (const v of result.verdicts) {
|
|
2223
|
+
if (v.verdict === 'OK')
|
|
2224
|
+
continue;
|
|
2225
|
+
console.error(` - ${v.verdict}: ${v.path}`);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
if (result.authorityGaps.length > 0) {
|
|
2229
|
+
console.error('Some decisions have no owner:');
|
|
2230
|
+
for (const gap of result.authorityGaps)
|
|
2231
|
+
console.error(` - ${gap.lookup.kind}: ${gap.domain}`);
|
|
2232
|
+
}
|
|
2233
|
+
console.error('\nAlternatives (not executed — the Controller decides):');
|
|
2234
|
+
for (const suggestion of result.suggestions)
|
|
2235
|
+
console.error(` · ${suggestion}`);
|
|
2236
|
+
return 1;
|
|
2237
|
+
}
|
|
2238
|
+
/** 작업 "중" 가시성 (B-17). 기록은 owner만, 표시는 사람 말로. */
|
|
2239
|
+
async function runProgress(command, target, values, store) {
|
|
2240
|
+
const service = progressService(store);
|
|
2241
|
+
if (command === 'show') {
|
|
2242
|
+
// 지목이 없으면 지금 돌고 있는 것을 보여준다 — 사람이 id를 외우게 하지 않는다
|
|
2243
|
+
const active = (await store.list('session')).filter((s) => target ? s.id === target : s.status === 'ACTIVE' || s.status === 'PAUSED');
|
|
2244
|
+
// 거둔 세션은 활성 목록에 없다. 종결 보고가 남아 있으면 그것만으로 보여준다.
|
|
2245
|
+
const targets = active.length > 0
|
|
2246
|
+
? active.map((s) => ({ id: s.id, session: s }))
|
|
2247
|
+
: target && (await service.get(target))
|
|
2248
|
+
? [{ id: target, session: null }]
|
|
2249
|
+
: [];
|
|
2250
|
+
if (targets.length === 0) {
|
|
2251
|
+
console.log(target ? `${target} was not found.` : 'No work in progress.');
|
|
2252
|
+
return target ? 1 : 0;
|
|
2253
|
+
}
|
|
2254
|
+
const openEscalations = await escalationLedger(store).pending();
|
|
2255
|
+
for (const { id, session } of targets) {
|
|
2256
|
+
const liveness = await livenessOf(store, id);
|
|
2257
|
+
const awaiting = openEscalations.filter((record) => record.sessionId === id).map((r) => r.escalationId);
|
|
2258
|
+
const rendered = renderProgress({
|
|
2259
|
+
session,
|
|
2260
|
+
progress: await service.get(id),
|
|
2261
|
+
...(liveness ? { liveness } : {}),
|
|
2262
|
+
...(awaiting.length > 0 ? { awaiting } : {}),
|
|
2263
|
+
});
|
|
2264
|
+
if (values.json) {
|
|
2265
|
+
console.log(JSON.stringify({ session: id, ...rendered }, null, 2));
|
|
2266
|
+
}
|
|
2267
|
+
else {
|
|
2268
|
+
console.log(rendered.body.join('\n\n'));
|
|
2269
|
+
console.log(`\n> detail: ${rendered.detail}`);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
return 0;
|
|
2273
|
+
}
|
|
2274
|
+
if (command === 'report') {
|
|
2275
|
+
if (!target || !values.physical || !values.phase) {
|
|
2276
|
+
console.error('Usage: asc progress report <S-ID> --physical <id> --phase "what is happening now"');
|
|
2277
|
+
return 2;
|
|
2278
|
+
}
|
|
2279
|
+
const decision = parseEnumArg(values.decision, ['none', 'later', 'now'], 'decision');
|
|
2280
|
+
const verifier = parseEnumArg(values.verifier, ['none', 'running', 'pass', 'fail'], 'verifier');
|
|
2281
|
+
if (decision === null || verifier === null)
|
|
2282
|
+
return 2;
|
|
2283
|
+
const outcome = await service.report(target, values.physical, {
|
|
2284
|
+
phase: values.phase,
|
|
2285
|
+
...(values.milestone ? { milestones: values.milestone } : {}),
|
|
2286
|
+
...(values.next ? { nextStep: values.next } : {}),
|
|
2287
|
+
...(values.unresolved ? { unresolved: values.unresolved } : {}),
|
|
2288
|
+
...(decision ? { needsUserDecision: decision.toUpperCase() } : {}),
|
|
2289
|
+
...(values['decision-ref'] ? { decisionRef: values['decision-ref'] } : {}),
|
|
2290
|
+
...(verifier ? { verifier: verifier.toUpperCase() } : {}),
|
|
2291
|
+
...(values['verifier-detail'] ? { verifierDetail: values['verifier-detail'] } : {}),
|
|
2292
|
+
...(values.terminal ? { terminal: true } : {}),
|
|
2293
|
+
});
|
|
2294
|
+
if (!outcome.ok) {
|
|
2295
|
+
console.error(`Could not record the progress report (${outcome.reason}): ${outcome.detail}`);
|
|
2296
|
+
return 1;
|
|
2297
|
+
}
|
|
2298
|
+
console.log(`${target} progress recorded — ${outcome.report.phase}`);
|
|
2299
|
+
return 0;
|
|
2300
|
+
}
|
|
2301
|
+
console.error(`Unknown progress command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
2302
|
+
return 2;
|
|
2303
|
+
}
|
|
2304
|
+
/** 잘못된 값을 조용히 기본값으로 흘리지 않는다 — 오타가 상태를 왜곡하면 표시를 못 믿는다. */
|
|
2305
|
+
function parseEnumArg(raw, allowed, flag) {
|
|
2306
|
+
if (raw === undefined)
|
|
2307
|
+
return undefined;
|
|
2308
|
+
if (allowed.includes(raw))
|
|
2309
|
+
return raw;
|
|
2310
|
+
console.error(`Invalid value for --${flag}: ${String(raw)} (allowed: ${allowed.join('|')})`);
|
|
2311
|
+
return null;
|
|
2312
|
+
}
|
|
2313
|
+
/** 한 회차 감지. 읽기만 하고, 무엇을 할지는 사람이 정한다. */
|
|
2314
|
+
/**
|
|
2315
|
+
* 감시 엔진을 세운다. **조립과 출력을 나눈다** — 상시 Runtime(C-12)이 같은 엔진을
|
|
2316
|
+
* 주기적으로 부르려면 조립이 명령 출력 안에 갇혀 있으면 안 된다.
|
|
2317
|
+
*/
|
|
2318
|
+
async function buildMonitorEngine(values, store, resolved) {
|
|
2319
|
+
const token = await discoverToken();
|
|
2320
|
+
if (!token) {
|
|
2321
|
+
console.error('No GitHub token found. Set ASC_GITHUB_TOKEN, or run `gh auth login`.');
|
|
2322
|
+
return { ok: false, code: 2 };
|
|
2323
|
+
}
|
|
2324
|
+
// 감시 대상도, 누가 나인지도, 누가 승인자인지도 전부 설정에서 온다.
|
|
2325
|
+
// 명령줄로 받던 값들은 각자 제자리(Profile·Override)를 찾았다.
|
|
2326
|
+
const repo = resolved.layers.profile.project.repository;
|
|
2327
|
+
const approver = values.as ?? Object.keys(resolved.controllerIdentities)[0];
|
|
2328
|
+
if (!approver) {
|
|
2329
|
+
console.error('No approver is known. Fill in controller.identities in override.json, or pass --as.');
|
|
2330
|
+
return { ok: false, code: 2 };
|
|
2331
|
+
}
|
|
2332
|
+
if ((resolved.monitor.identities?.length ?? 0) === 0) {
|
|
2333
|
+
// 누가 나인지 모르면 mention·assign 신호가 서지 않아 감지가 사실상 비어버린다
|
|
2334
|
+
console.error('Warning: monitorIdentities in override.json is empty — things addressed to you cannot be recognised.');
|
|
2335
|
+
}
|
|
2336
|
+
// provider를 CLI가 고르지 않는다. 선언된 요구와 실제 remote에서 Binding이 풀리고,
|
|
2337
|
+
// 갈리면 고르지 않고 말한다 — silent substitution 0 (C-11 §7).
|
|
2338
|
+
const { root: projectRoot } = await discoverProjectRoot(process.cwd());
|
|
2339
|
+
const adapters = defaultAdapters();
|
|
2340
|
+
const declared = resolved.layers.profile.bindings ?? [];
|
|
2341
|
+
const plan = await composeBindings({
|
|
2342
|
+
context: { projectRoot, env: process.env },
|
|
2343
|
+
adapters,
|
|
2344
|
+
roles: declared.map((b) => ({ adapterId: b.adapter, resource: b.resource, role: b.role })),
|
|
2345
|
+
});
|
|
2346
|
+
const ports = await buildRuntimePorts({
|
|
2347
|
+
plan,
|
|
2348
|
+
roles: rolesFor(plan, declared),
|
|
2349
|
+
perPage: 30,
|
|
2350
|
+
endpointFor: (binding) => endpointOf(adapters, binding),
|
|
2351
|
+
});
|
|
2352
|
+
if (!ports.eventSource) {
|
|
2353
|
+
console.error('No monitoring channel could be built:');
|
|
2354
|
+
for (const reason of ports.unavailable)
|
|
2355
|
+
console.error(` - ${reason}`);
|
|
2356
|
+
console.error('\nDeclare which provider takes which role in the Profile bindings.');
|
|
2357
|
+
return { ok: false, code: 2 };
|
|
2358
|
+
}
|
|
2359
|
+
const canonicalPaths = resolved.layers.profile.canonical.sources.flatMap((source) => source.paths);
|
|
2360
|
+
const myRoles = resolved.layers.override?.monitor.roles ?? [];
|
|
2361
|
+
const changeContext = ports.changeContext;
|
|
2362
|
+
const engine = new MonitorEngine({
|
|
2363
|
+
store,
|
|
2364
|
+
source: ports.eventSource,
|
|
2365
|
+
...(ports.scm ? { scm: ports.scm } : {}),
|
|
2366
|
+
...(ports.inventory ? { inventory: ports.inventory } : {}),
|
|
2367
|
+
// 밖에서 알아 온 사실을 실제로 공급한다 (C-07 §2~§4). 이것이 없으면 Relevance·
|
|
2368
|
+
// Shadow·Material Change가 코드에만 있고 실행 경로에는 없다.
|
|
2369
|
+
...(changeContext
|
|
2370
|
+
? {
|
|
2371
|
+
observe: buildEventObservation({
|
|
2372
|
+
change: changeContext,
|
|
2373
|
+
...(resolved.ownership ? { ownership: resolved.ownership } : {}),
|
|
2374
|
+
...(myRoles.length ? { myRoles } : {}),
|
|
2375
|
+
...(canonicalPaths.length ? { canonicalPaths } : {}),
|
|
2376
|
+
}),
|
|
2377
|
+
}
|
|
2378
|
+
: {}),
|
|
2379
|
+
// scope는 실제로 붙은 source가 정한다 — provider 이름을 CLI가 박지 않는다.
|
|
2380
|
+
observations: new ObservationLedger(store.scope(monitorScope(ports.eventSource.id))),
|
|
2381
|
+
// 조사 단계가 요청하는 통로들. 없는 것은 그 단계가 판정 불성립으로 남는다 (C-07 §6.2).
|
|
2382
|
+
// 없는 것을 있는 척하지 않는다.
|
|
2383
|
+
investigation: {
|
|
2384
|
+
...(ports.resourceContext ? { resource: ports.resourceContext } : {}),
|
|
2385
|
+
...(changeContext ? { change: changeContext } : {}),
|
|
2386
|
+
},
|
|
2387
|
+
// Core는 Profile을 모른다 — 조사에 필요한 프로젝트 사실을 Surface가 꺼내 준다.
|
|
2388
|
+
investigationContext: () => ({
|
|
2389
|
+
...(resolved.ownership ? { ownership: resolved.ownership } : {}),
|
|
2390
|
+
canonicalPaths,
|
|
2391
|
+
}),
|
|
2392
|
+
config: resolved.monitor,
|
|
2393
|
+
authorizedApprover: approver,
|
|
2394
|
+
canonicalSources: resolved.canonicalSources,
|
|
2395
|
+
// 처음 돌 때 과거를 통째로 긁으면 사람이 읽을 수 없다 (OM §18)
|
|
2396
|
+
...(values.backfill ? {} : { startFrom: new Date().toISOString() }),
|
|
2397
|
+
});
|
|
2398
|
+
await rememberMonitorSource(store, ports.eventSource.id);
|
|
2399
|
+
// **밀려난 후보를 말한다.** 실 프로젝트 관측에서 나온 것이다: primary가 자격 없음으로
|
|
2400
|
+
// 빠지고 mirror에 조용히 붙으면 "감시가 도는데 아무것도 안 잡히는" 상태가 된다.
|
|
2401
|
+
// 고르는 것 자체는 막지 않되, 무엇이 밀렸는지 모르게 두지 않는다 (C-11 §7).
|
|
2402
|
+
const skipped = plan.bindings.filter((b) => b.state !== 'AVAILABLE' && b.state !== 'DEGRADED');
|
|
2403
|
+
if (skipped.length > 0) {
|
|
2404
|
+
console.error(`Monitoring runs on ${ports.eventSource.id}. Some candidates could not be used:`);
|
|
2405
|
+
for (const binding of skipped) {
|
|
2406
|
+
console.error(` - ${binding.adapterId}:${binding.resource} — ${binding.state}${binding.detail ? ` (${binding.detail})` : ''}`);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
for (const reason of ports.unavailable)
|
|
2410
|
+
console.error(` - ${reason}`);
|
|
2411
|
+
return { ok: true, engine, sourceId: ports.eventSource.id, repo };
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* 상시 Runtime (C-12). 대화를 켜 두지 않고 **상태를 지속시키고 계산을 짧게 돌린다.**
|
|
2415
|
+
*
|
|
2416
|
+
* 여기는 계기만 갖는다 — 판정도 승인도 하지 않고, 사람이 부르던 것과 같은 함수를 부른다.
|
|
2417
|
+
* 재기동해도 cursor·lease·observation이 저장소에 있으므로 같은 사건을 다시 만들지 않는다.
|
|
2418
|
+
*/
|
|
2419
|
+
async function runRuntime(command, values, store, renderer, resolved) {
|
|
2420
|
+
if (command !== 'start' && command !== 'tick') {
|
|
2421
|
+
console.error(`Unknown runtime command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
2422
|
+
return 2;
|
|
2423
|
+
}
|
|
2424
|
+
if (!resolved) {
|
|
2425
|
+
console.error('This only runs inside an attached project. Run `asc init --profile <id>` first.');
|
|
2426
|
+
return 2;
|
|
2427
|
+
}
|
|
2428
|
+
const built = await buildMonitorEngine(values, store, resolved);
|
|
2429
|
+
if (!built.ok)
|
|
2430
|
+
return built.code;
|
|
2431
|
+
const { engine } = built;
|
|
2432
|
+
const minutes = (value, fallback) => value === undefined ? fallback * 60_000 : Number(value) * 60_000;
|
|
2433
|
+
// 주기는 Core 상수가 아니다 (C-12 불변식 ③). 명령줄이 정하고, 기본값은 여기 산다.
|
|
2434
|
+
const schedule = {
|
|
2435
|
+
deltaMs: minutes(values['delta-min'], 5),
|
|
2436
|
+
reconcileMs: minutes(values['reconcile-min'], 60),
|
|
2437
|
+
censusMs: minutes(values['census-min'], 24 * 60),
|
|
2438
|
+
digestMs: minutes(values['digest-min'], 60),
|
|
2439
|
+
};
|
|
2440
|
+
const scope = store.scope('runtime');
|
|
2441
|
+
const orchestrator = new Orchestrator({
|
|
2442
|
+
schedule,
|
|
2443
|
+
log: (line) => console.log(line),
|
|
2444
|
+
lastRunAt: {
|
|
2445
|
+
read: async () => {
|
|
2446
|
+
const raw = await scope.get('last-run');
|
|
2447
|
+
return raw ? JSON.parse(raw) : {};
|
|
2448
|
+
},
|
|
2449
|
+
write: async (kind, at) => {
|
|
2450
|
+
const raw = await scope.get('last-run');
|
|
2451
|
+
const current = raw ? JSON.parse(raw) : {};
|
|
2452
|
+
await scope.set('last-run', JSON.stringify({ ...current, [kind]: at }));
|
|
2453
|
+
},
|
|
2454
|
+
},
|
|
2455
|
+
actions: {
|
|
2456
|
+
delta: async () => {
|
|
2457
|
+
const outcome = await engine.scan();
|
|
2458
|
+
if (outcome.skipped)
|
|
2459
|
+
return;
|
|
2460
|
+
console.log(` detected ${outcome.detected} · logged ${outcome.logged} · packets ${outcome.packets.length}`);
|
|
2461
|
+
},
|
|
2462
|
+
reconcile: async () => {
|
|
2463
|
+
const sweep = await engine.reconcile();
|
|
2464
|
+
if (!sweep.skipped && !sweep.complete) {
|
|
2465
|
+
// 못 본 것을 "변경 없음"으로 읽지 않는다 (C-12 불변식 ⑫)
|
|
2466
|
+
console.error(` Reconcile did not complete${sweep.detail ? ` — ${sweep.detail}` : ''}`);
|
|
2467
|
+
}
|
|
2468
|
+
},
|
|
2469
|
+
census: async () => {
|
|
2470
|
+
const sweep = await engine.census();
|
|
2471
|
+
if (!sweep.skipped && sweep.missing.length > 0) {
|
|
2472
|
+
console.log(` ${sweep.missing.length} known items absent from this listing`);
|
|
2473
|
+
}
|
|
2474
|
+
},
|
|
2475
|
+
digest: async () => {
|
|
2476
|
+
const outcome = await deliverDigest(store, false);
|
|
2477
|
+
if (outcome > 0)
|
|
2478
|
+
console.error(' delivery failed');
|
|
2479
|
+
},
|
|
2480
|
+
},
|
|
2481
|
+
});
|
|
2482
|
+
if (command === 'tick') {
|
|
2483
|
+
const outcome = await orchestrator.tick();
|
|
2484
|
+
console.log(renderTick(outcome));
|
|
2485
|
+
return outcome.failures.length > 0 ? 1 : 0;
|
|
2486
|
+
}
|
|
2487
|
+
const intervalMs = minutes(values['interval-min'], 1);
|
|
2488
|
+
console.log(`Always-on runtime started — one pass every ${intervalMs / 60_000} min. Ctrl+C to stop.`);
|
|
2489
|
+
const stop = () => {
|
|
2490
|
+
console.log('Stopping — a pass still running is carried to the next start by its lease.');
|
|
2491
|
+
orchestrator.stop();
|
|
2492
|
+
};
|
|
2493
|
+
process.once('SIGINT', stop);
|
|
2494
|
+
process.once('SIGTERM', stop);
|
|
2495
|
+
await orchestrator.run(intervalMs, (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref?.()));
|
|
2496
|
+
return 0;
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* 새 Front Session이 붙었다. 지금 무엇이 걸려 있는지 한 화면으로 되찾는다.
|
|
2500
|
+
*
|
|
2501
|
+
* **읽기만 한다** — 전이도, 승인 대기 소비도, 소유권 주장도 없다 (C-12 불변식 ⑮).
|
|
2502
|
+
*/
|
|
2503
|
+
/** 상신 원장 (C-13). 회수 뒤에도 남는 기록이라 adapter-scope에 산다. */
|
|
2504
|
+
const escalationLedger = (store) => new EscalationLedger(store.scope('escalation'));
|
|
2505
|
+
/**
|
|
2506
|
+
* 사람에게 올린다 — **자격이 있을 때만**.
|
|
2507
|
+
*
|
|
2508
|
+
* predicate가 없으면 ApprovalRequest는 만들어지지 않고, 막힌 사실만 남는다 (C-13 §1.2).
|
|
2509
|
+
* 결정 표면은 기존 inbox다 — 두 번째 승인 창구를 만들지 않는다.
|
|
2510
|
+
*/
|
|
2511
|
+
async function runEscalate(command, target, values, store, resolved) {
|
|
2512
|
+
const ledger = escalationLedger(store);
|
|
2513
|
+
if (command === 'list' || command === undefined) {
|
|
2514
|
+
const pending = await ledger.pending();
|
|
2515
|
+
for (const line of escalationLines(pending))
|
|
2516
|
+
console.log(line);
|
|
2517
|
+
if (pending.length === 0)
|
|
2518
|
+
console.log('No open escalations');
|
|
2519
|
+
const rejected = await ledger.rejected();
|
|
2520
|
+
if (rejected.length > 0) {
|
|
2521
|
+
// Gate가 무엇을 막았는지 보이지 않으면 Gate가 도는지 알 수 없다
|
|
2522
|
+
console.log(`\n${rejected.length} attempts that were refused:`);
|
|
2523
|
+
for (const item of rejected)
|
|
2524
|
+
console.log(` [${item.reason}] ${item.question} — ${item.detail}`);
|
|
2525
|
+
}
|
|
2526
|
+
return 0;
|
|
2527
|
+
}
|
|
2528
|
+
if (command === 'open') {
|
|
2529
|
+
if (!target) {
|
|
2530
|
+
console.error('Usage: asc escalate open <S-ID> --predicate <p>... --question <t> --blocked <node>...');
|
|
2531
|
+
return 2;
|
|
2532
|
+
}
|
|
2533
|
+
const question = values.question;
|
|
2534
|
+
if (!question) {
|
|
2535
|
+
console.error('--question is required. It is the one sentence a person answers.');
|
|
2536
|
+
return 2;
|
|
2537
|
+
}
|
|
2538
|
+
const session = await store.get('session', target);
|
|
2539
|
+
if (!session) {
|
|
2540
|
+
console.error(`${target} was not found.`);
|
|
2541
|
+
return 1;
|
|
2542
|
+
}
|
|
2543
|
+
// 결정할 사람을 여기서 먼저 정한다. Gate를 통과시켜 record를 남긴 뒤에 "누구에게
|
|
2544
|
+
// 올릴지 모르겠다"고 끊으면, 아무도 못 본 상신이 남아 같은 경계를 다시 막는다.
|
|
2545
|
+
const approver = values.as ?? Object.keys(resolved?.controllerIdentities ?? {})[0];
|
|
2546
|
+
if (!approver) {
|
|
2547
|
+
console.error('No approver is known — fill in controller.identities in override.json, or pass --as.');
|
|
2548
|
+
return 2;
|
|
2549
|
+
}
|
|
2550
|
+
const opened = await ledger.open({
|
|
2551
|
+
escalationId: values.id ?? nextEscalationId(await ledger.all()),
|
|
2552
|
+
sessionId: target,
|
|
2553
|
+
openedBy: values.as ?? values.principal ?? session.owner ?? '(미상)',
|
|
2554
|
+
predicates: values.predicate ?? [],
|
|
2555
|
+
question,
|
|
2556
|
+
evidenceRefs: values.evidence ?? [],
|
|
2557
|
+
blockedNodes: values.blocked ?? [],
|
|
2558
|
+
...(values['blocked-scope'] ? { blockedScope: values['blocked-scope'] } : {}),
|
|
2559
|
+
...(values.affected ? { affectedNodes: values.affected } : {}),
|
|
2560
|
+
doneCriteria: session.doneCriteria,
|
|
2561
|
+
...(values.previous ? { previousEscalationId: values.previous } : {}),
|
|
2562
|
+
...(values.why ? { whyPreviousDecisionDoesNotCoverThis: values.why.join(' · ') } : {}),
|
|
2563
|
+
});
|
|
2564
|
+
if (!opened.ok) {
|
|
2565
|
+
console.error(opened.detail);
|
|
2566
|
+
if (opened.reason === 'APPROVAL_NOT_JUSTIFIED') {
|
|
2567
|
+
console.error('\nThese are the only grounds that qualify for escalation (C-13 §1.1):');
|
|
2568
|
+
console.error(' ownership_boundary · shared_contract_change · acceptance_change');
|
|
2569
|
+
console.error(' secret_or_permission · irreversible_action · explicit_rule_requires_approval');
|
|
2570
|
+
console.error(' canonical_conflict');
|
|
2571
|
+
console.error('Being uncertain, or having several options, is not a boundary — gather the evidence, decide, and proceed.');
|
|
2572
|
+
}
|
|
2573
|
+
return 1;
|
|
2574
|
+
}
|
|
2575
|
+
const record = opened.record;
|
|
2576
|
+
// 결정 표면은 기존 Approval이다 (C-01 무수정). 여기서 새 창구를 만들지 않는다.
|
|
2577
|
+
const requestId = await nextRequestId(store);
|
|
2578
|
+
const created = await store.create('request', {
|
|
2579
|
+
id: requestId,
|
|
2580
|
+
version: 0,
|
|
2581
|
+
status: 'AWAITING_APPROVAL',
|
|
2582
|
+
// EventType은 3종뿐이다(OM). 상신은 사람의 행동을 요구하므로 actionable이다.
|
|
2583
|
+
type: 'actionable',
|
|
2584
|
+
priority: 'P1',
|
|
2585
|
+
title: record.question,
|
|
2586
|
+
detectedAt: record.openedAt,
|
|
2587
|
+
source: { eventKey: `escalation:${record.escalationId}`, reference: record.sessionId },
|
|
2588
|
+
situation: record.question,
|
|
2589
|
+
context: escalationLines([record]).join('\n'),
|
|
2590
|
+
impact: {
|
|
2591
|
+
interruptRequired: false,
|
|
2592
|
+
affectedSessions: [record.sessionId],
|
|
2593
|
+
// 무엇이 계속 가는지 함께 적는다 — 전체가 선 것처럼 읽히지 않게
|
|
2594
|
+
rationale: `막힌 것 ${record.blockedNodes.join(', ')} · 계속 가는 것 ${record.stillRunnableNodes.join(', ') || '없음'}`,
|
|
2595
|
+
},
|
|
2596
|
+
recommendation: '',
|
|
2597
|
+
snapshot: [],
|
|
2598
|
+
authorizedApprover: approver,
|
|
2599
|
+
allowedDecisions: ['approve', 'revise', 'defer', 'dismiss'],
|
|
2600
|
+
escalation: {
|
|
2601
|
+
escalationId: record.escalationId,
|
|
2602
|
+
predicates: record.predicates,
|
|
2603
|
+
evidenceRefs: record.evidenceRefs,
|
|
2604
|
+
affectedNodes: record.affectedNodes,
|
|
2605
|
+
blockedNodes: record.blockedNodes,
|
|
2606
|
+
blockedScope: record.blockedScope,
|
|
2607
|
+
stillRunnableNodes: record.stillRunnableNodes,
|
|
2608
|
+
...(record.previousEscalationId ? { previousEscalationId: record.previousEscalationId } : {}),
|
|
2609
|
+
},
|
|
2610
|
+
});
|
|
2611
|
+
if (!created.ok) {
|
|
2612
|
+
// Gate는 이미 통과해 record가 남았다. 그 사실을 숨기면 사람은 같은 경계를 다시
|
|
2613
|
+
// 올리려다 "이미 열려 있다"는 말만 듣고 이유를 알 수 없다.
|
|
2614
|
+
console.error(`Could not create the request: ${created.reason}`);
|
|
2615
|
+
console.error(`${record.escalationId} 은 열린 채 아무에게도 가지 못했다 — 결정 표면이 없다.` +
|
|
2616
|
+
' 저장 문제를 고친 뒤 다시 시도하라.');
|
|
2617
|
+
return 1;
|
|
2618
|
+
}
|
|
2619
|
+
await ledger.attachRequest(record.escalationId, requestId);
|
|
2620
|
+
for (const line of escalationLines([record]))
|
|
2621
|
+
console.log(line);
|
|
2622
|
+
console.log(`\nRaised as ${requestId} — decide it with \`asc inbox decide ${requestId}\`.`);
|
|
2623
|
+
if (record.stillRunnableNodes.length > 0) {
|
|
2624
|
+
console.log(`Only ${record.blockedNodes.join(', ')} is blocked. The rest keeps running.`);
|
|
2625
|
+
}
|
|
2626
|
+
return 0;
|
|
2627
|
+
}
|
|
2628
|
+
if (command === 'resolve') {
|
|
2629
|
+
if (!target) {
|
|
2630
|
+
console.error('Usage: asc escalate resolve <ESC-ID> --as <actor>');
|
|
2631
|
+
return 2;
|
|
2632
|
+
}
|
|
2633
|
+
const record = await ledger.get(target);
|
|
2634
|
+
if (!record) {
|
|
2635
|
+
console.error(`${target} was not found.`);
|
|
2636
|
+
return 1;
|
|
2637
|
+
}
|
|
2638
|
+
const request = await store.get('request', record.requestId);
|
|
2639
|
+
if (!request?.decision) {
|
|
2640
|
+
// 사람이 결정하지 않았는데 닫으면 외부 대기를 가짜로 해소하는 것이다
|
|
2641
|
+
console.error(`${record.requestId} has no decision yet — an escalation closes only on a decision.`);
|
|
2642
|
+
return 1;
|
|
2643
|
+
}
|
|
2644
|
+
const outcome = await ledger.resolve(target, request.decision.actor, `${record.requestId}:${request.decision.kind}`);
|
|
2645
|
+
if (!outcome.ok) {
|
|
2646
|
+
console.error(outcome.detail);
|
|
2647
|
+
return 1;
|
|
2648
|
+
}
|
|
2649
|
+
console.log(`${target} closed — ${request.decision.kind} by ${request.decision.actor}`);
|
|
2650
|
+
return 0;
|
|
2651
|
+
}
|
|
2652
|
+
console.error(`Unknown escalate command: ${command}\n\n${USAGE}`);
|
|
2653
|
+
return 2;
|
|
2654
|
+
}
|
|
2655
|
+
/** `ESC-YYYYMMDD-NN`. 세션 id와 같은 모양으로 읽힌다. */
|
|
2656
|
+
function nextEscalationId(existing) {
|
|
2657
|
+
const day = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
2658
|
+
const prefix = `ESC-${day}-`;
|
|
2659
|
+
const used = existing
|
|
2660
|
+
.filter((record) => record.escalationId.startsWith(prefix))
|
|
2661
|
+
.map((record) => Number(record.escalationId.slice(prefix.length)))
|
|
2662
|
+
.filter((n) => Number.isFinite(n));
|
|
2663
|
+
return `${prefix}${String((used.length > 0 ? Math.max(...used) : 0) + 1).padStart(2, '0')}`;
|
|
2664
|
+
}
|
|
2665
|
+
async function nextRequestId(store) {
|
|
2666
|
+
const existing = await store.list('request');
|
|
2667
|
+
const numbers = existing.map((r) => Number(r.id.slice(4))).filter((n) => Number.isFinite(n));
|
|
2668
|
+
return `REQ-${String((numbers.length > 0 ? Math.max(...numbers) : 0) + 1).padStart(4, '0')}`;
|
|
2669
|
+
}
|
|
2670
|
+
/** 원격 동결 상태를 보고 바꾼다. **실행은 여기서 하지 않는다** — 미룬 것은 사람이 다시 본다. */
|
|
2671
|
+
async function runFreeze(group, command, values, store) {
|
|
2672
|
+
const ledger = new FreezeLedger(store.scope('policy'));
|
|
2673
|
+
if (group === 'thaw') {
|
|
2674
|
+
const { policy, deferred } = await ledger.thaw();
|
|
2675
|
+
for (const line of freezeLines(policy, deferred))
|
|
2676
|
+
console.log(line);
|
|
2677
|
+
if (deferred.length > 0) {
|
|
2678
|
+
// 자동 재생 금지 (지시 §27). 녹였다고 쌓인 것이 나가면 그 사이 바뀐 대상에 나간다.
|
|
2679
|
+
console.log('\nDeferred items do not go out on their own. Re-check each and send it through the approval path.');
|
|
2680
|
+
}
|
|
2681
|
+
return 0;
|
|
2682
|
+
}
|
|
2683
|
+
if (command === 'status' || command === undefined) {
|
|
2684
|
+
for (const line of freezeLines(await ledger.policy(), await ledger.deferred()))
|
|
2685
|
+
console.log(line);
|
|
2686
|
+
return 0;
|
|
2687
|
+
}
|
|
2688
|
+
if (command === 'on') {
|
|
2689
|
+
const reason = values.reason;
|
|
2690
|
+
if (!reason) {
|
|
2691
|
+
// 이유 없는 freeze는 다음 사람이 언제 녹여도 되는지 모른다
|
|
2692
|
+
console.error('--reason is required. Without knowing why it was frozen, nobody knows when to thaw it.');
|
|
2693
|
+
return 2;
|
|
2694
|
+
}
|
|
2695
|
+
const policy = await ledger.freeze(reason, { denyRemoteRead: Boolean(values.offline) });
|
|
2696
|
+
for (const line of freezeLines(policy, await ledger.deferred()))
|
|
2697
|
+
console.log(line);
|
|
2698
|
+
return 0;
|
|
2699
|
+
}
|
|
2700
|
+
if (command === 'defer') {
|
|
2701
|
+
const id = values.id;
|
|
2702
|
+
const intent = values.intent;
|
|
2703
|
+
if (!id || !intent) {
|
|
2704
|
+
console.error('Usage: asc freeze defer --id <id> --intent "what was being attempted"');
|
|
2705
|
+
return 2;
|
|
2706
|
+
}
|
|
2707
|
+
const added = await ledger.defer({
|
|
2708
|
+
id,
|
|
2709
|
+
action: 'remote.write',
|
|
2710
|
+
intent,
|
|
2711
|
+
basis: values.evidence ?? [],
|
|
2712
|
+
...(values.grant ? { grantRef: values.grant } : {}),
|
|
2713
|
+
});
|
|
2714
|
+
console.log(added ? `${id} deferred` : `${id} is already on the deferred list`);
|
|
2715
|
+
return added ? 0 : 1;
|
|
2716
|
+
}
|
|
2717
|
+
if (command === 'release') {
|
|
2718
|
+
const id = values.id;
|
|
2719
|
+
if (!id) {
|
|
2720
|
+
console.error('Usage: asc freeze release --id <id>');
|
|
2721
|
+
return 2;
|
|
2722
|
+
}
|
|
2723
|
+
const removed = await ledger.release(id);
|
|
2724
|
+
console.log(removed ? `${id} removed from the list — execution goes through the approval path` : `${id} was not found`);
|
|
2725
|
+
return removed ? 0 : 1;
|
|
2726
|
+
}
|
|
2727
|
+
console.error(`Unknown freeze command: ${command}
|
|
2728
|
+
|
|
2729
|
+
${USAGE}`);
|
|
2730
|
+
return 2;
|
|
2731
|
+
}
|
|
2732
|
+
async function runFront(command, values, store, root) {
|
|
2733
|
+
if (command !== undefined && command !== 'status') {
|
|
2734
|
+
console.error(`Unknown front command: ${command}
|
|
2735
|
+
|
|
2736
|
+
${USAGE}`);
|
|
2737
|
+
return 2;
|
|
2738
|
+
}
|
|
2739
|
+
const scope = await activeMonitorScope(store);
|
|
2740
|
+
const index = await readIndex(ascHome());
|
|
2741
|
+
const located = lookupLocator(index, process.cwd());
|
|
2742
|
+
const state = await restoreFront({
|
|
2743
|
+
store,
|
|
2744
|
+
pending: await new LocalOperator({ store }).list({}),
|
|
2745
|
+
escalations: await escalationLedger(store).pending(),
|
|
2746
|
+
health: evaluateHealth(await new CoverageLedger(store.scope(scope)).health(), new Date().toISOString(), HEALTH_THRESHOLDS),
|
|
2747
|
+
...(located ? { workspace: { workspaceId: located.workspaceId, locator: located.locator } } : {}),
|
|
2748
|
+
// 도는 세션을 누가 집고 있는지. --physical 을 다시 물어보게 하지 않는다 (L-4).
|
|
2749
|
+
bindings: claudeBindings(store),
|
|
2750
|
+
});
|
|
2751
|
+
if (values.json) {
|
|
2752
|
+
console.log(JSON.stringify({ ...state, root }, null, 2));
|
|
2753
|
+
return 0;
|
|
2754
|
+
}
|
|
2755
|
+
for (const line of renderFront(state))
|
|
2756
|
+
console.log(line);
|
|
2757
|
+
return 0;
|
|
2758
|
+
}
|
|
2759
|
+
async function runMonitor(command, values, store, renderer, resolved) {
|
|
2760
|
+
if (command !== 'scan' && command !== 'reconcile' && command !== 'census' && command !== 'status') {
|
|
2761
|
+
console.error(`Unknown monitor command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
2762
|
+
return 2;
|
|
2763
|
+
}
|
|
2764
|
+
if (!resolved) {
|
|
2765
|
+
console.error('This only runs inside an attached project. Run `asc init --profile <id>` first.');
|
|
2766
|
+
return 2;
|
|
2767
|
+
}
|
|
2768
|
+
// 완전 오프라인이면 외부를 치지 않는다. 조용히 도는 것보다 왜 못 도는지 말하는 게 낫다.
|
|
2769
|
+
const frozen = await new FreezeLedger(store.scope('policy')).policy();
|
|
2770
|
+
const readJudgment = judgeAction(frozen, 'remote.read');
|
|
2771
|
+
if (readJudgment.decision === 'DENY') {
|
|
2772
|
+
console.error(`Monitoring is not running — ${readJudgment.detail}`);
|
|
2773
|
+
return 2;
|
|
2774
|
+
}
|
|
2775
|
+
const built = await buildMonitorEngine(values, store, resolved);
|
|
2776
|
+
if (!built.ok)
|
|
2777
|
+
return built.code;
|
|
2778
|
+
const { engine, repo } = built;
|
|
2779
|
+
// 어디까지 확인했는지 보여주기만 한다. 판정하지도, 무엇을 고치지도 않는다.
|
|
2780
|
+
if (command === 'status') {
|
|
2781
|
+
const health = await engine.health();
|
|
2782
|
+
console.log(values.json ? JSON.stringify(health, null, 2) : renderHealth(repo, health).join('\n'));
|
|
2783
|
+
return 0;
|
|
2784
|
+
}
|
|
2785
|
+
if (command === 'reconcile' || command === 'census') {
|
|
2786
|
+
const sweep = command === 'reconcile' ? await engine.reconcile() : await engine.census();
|
|
2787
|
+
if (sweep.skipped) {
|
|
2788
|
+
console.log('Another run is in progress. The next pass will pick it up.');
|
|
2789
|
+
return 0;
|
|
2790
|
+
}
|
|
2791
|
+
if (values.json) {
|
|
2792
|
+
console.log(JSON.stringify(sweep, null, 2));
|
|
2793
|
+
return sweep.complete ? 0 : 1;
|
|
2794
|
+
}
|
|
2795
|
+
console.log(`${sweep.kind}: ${sweep.seen} listed · ${sweep.changed} changed · ${sweep.packets.length} packets`);
|
|
2796
|
+
if (sweep.missing.length > 0) {
|
|
2797
|
+
console.log(`${sweep.missing.length} known items absent from this listing: ${sweep.missing.join(', ')}`);
|
|
2798
|
+
console.log(' (whether that is deletion, permissions, visibility or a query error is not judged here — a person looks)');
|
|
2799
|
+
}
|
|
2800
|
+
if (!sweep.complete) {
|
|
2801
|
+
// 완주하지 못한 회차를 성공으로 보이면 "확인했다"는 거짓말이 된다
|
|
2802
|
+
console.error(`The listing did not complete${sweep.detail ? ` — ${sweep.detail}` : ''}. This pass makes no judgement about disappearances.`);
|
|
2803
|
+
return 1;
|
|
2804
|
+
}
|
|
2805
|
+
return 0;
|
|
2806
|
+
}
|
|
2807
|
+
const outcome = await engine.scan();
|
|
2808
|
+
if (outcome.skipped) {
|
|
2809
|
+
console.log('Another scan is in progress. The next pass will pick it up.');
|
|
2810
|
+
return 0;
|
|
2811
|
+
}
|
|
2812
|
+
console.log(`감지 ${outcome.detected} · 중복 ${outcome.duplicates} · 기록 ${outcome.logged} · ` +
|
|
2813
|
+
`보고서 ${outcome.packets.length} · 재시도 ${outcome.retries.length}`);
|
|
2814
|
+
if (outcome.packets.length > 0) {
|
|
2815
|
+
const operator = new LocalOperator({ store });
|
|
2816
|
+
console.log();
|
|
2817
|
+
console.log(renderer.renderList(await operator.list()).text);
|
|
2818
|
+
}
|
|
2819
|
+
return 0;
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* 외부로 내보내는 경로. 승인과 분리돼 있는 이유는 OM §11.8 — 승인은 내용에 동의한
|
|
2823
|
+
* 것이고, 무엇을 어디에 쓸지는 Controller가 따로 지정한다.
|
|
2824
|
+
*/
|
|
2825
|
+
async function runGrant(command, target, values, store, root) {
|
|
2826
|
+
switch (command) {
|
|
2827
|
+
case 'issue': {
|
|
2828
|
+
if (!target || !values.action || !values.target || !values.as) {
|
|
2829
|
+
console.error('Usage: asc grant issue REQ-0042 --action <key> --target <ref> --as <actor>');
|
|
2830
|
+
return 2;
|
|
2831
|
+
}
|
|
2832
|
+
// 발급도 승인 권한자만 할 수 있다 — 외부로 나가는 권한이 여기서 만들어지기 때문이다
|
|
2833
|
+
const grants = new GrantService(store, new LocalIdentityBinding(await loadIdentityMap(root)));
|
|
2834
|
+
const issued = await grants.issue({
|
|
2835
|
+
grantId: values['grant-id'] ?? `G-${String(Date.now()).slice(-4)}`,
|
|
2836
|
+
requestId: target,
|
|
2837
|
+
issuedBy: values.as,
|
|
2838
|
+
channel: 'local',
|
|
2839
|
+
action: values.action,
|
|
2840
|
+
target: values.target,
|
|
2841
|
+
...(values.expires ? { expiresAt: values.expires } : {}),
|
|
2842
|
+
issuedAt: new Date().toISOString(),
|
|
2843
|
+
});
|
|
2844
|
+
if (!issued.ok) {
|
|
2845
|
+
console.error(GRANT_ERROR[issued.failure.kind] ?? issued.failure.kind);
|
|
2846
|
+
return 1;
|
|
2847
|
+
}
|
|
2848
|
+
console.log(`${issued.grant.id} READY — ${issued.grant.action} → ${issued.grant.target}`);
|
|
2849
|
+
console.log('The target state is checked again before execution. If it changed in the meantime, nothing runs.');
|
|
2850
|
+
return 0;
|
|
2851
|
+
}
|
|
2852
|
+
case 'run': {
|
|
2853
|
+
if (!target) {
|
|
2854
|
+
console.error('Usage: asc grant run G-0001');
|
|
2855
|
+
return 2;
|
|
2856
|
+
}
|
|
2857
|
+
const token = await discoverToken();
|
|
2858
|
+
if (!token) {
|
|
2859
|
+
console.error('No GitHub token found. Set ASC_GITHUB_TOKEN, or run `gh auth login`.');
|
|
2860
|
+
return 2;
|
|
2861
|
+
}
|
|
2862
|
+
const grant = await store.get('grant', target);
|
|
2863
|
+
if (!grant) {
|
|
2864
|
+
console.error(`${target} was not found.`);
|
|
2865
|
+
return 1;
|
|
2866
|
+
}
|
|
2867
|
+
// 이 한 번이 실제로 밖에 나간다. 계약이 지정한 대상·내용 그대로이며,
|
|
2868
|
+
// 직전에 대상 상태를 다시 확인한다.
|
|
2869
|
+
const scm = new GitHubScm({ client: new GitHubClient({ token }), defaultRepo: repoOf(grant.target) });
|
|
2870
|
+
const outcome = await new Executor({
|
|
2871
|
+
store,
|
|
2872
|
+
scm,
|
|
2873
|
+
runId: values['run-id'] ?? `cli-${process.pid}`,
|
|
2874
|
+
}).run(target);
|
|
2875
|
+
if (outcome.ok) {
|
|
2876
|
+
console.log(`EXECUTED — ${outcome.resultRef}`);
|
|
2877
|
+
return 0;
|
|
2878
|
+
}
|
|
2879
|
+
console.error(`${outcome.reason}${'detail' in outcome ? `: ${outcome.detail}` : ''}`);
|
|
2880
|
+
return 1;
|
|
2881
|
+
}
|
|
2882
|
+
default:
|
|
2883
|
+
console.error(`Unknown grant command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
2884
|
+
return 2;
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
/** `owner/repo#19` 에서 저장소만. 짧은 참조를 풀 때 쓴다. */
|
|
2888
|
+
function repoOf(target) {
|
|
2889
|
+
const match = /^([^/\s]+\/[^#\s]+)#\d+$/.exec(target);
|
|
2890
|
+
return match?.[1];
|
|
2891
|
+
}
|
|
2892
|
+
const GRANT_ERROR = {
|
|
2893
|
+
REQUEST_NOT_FOUND: '요청을 찾지 못했다.',
|
|
2894
|
+
NOT_APPROVED: '아직 승인되지 않은 요청이다. 승인 먼저 받아야 한다.',
|
|
2895
|
+
FORBIDDEN_ISSUER: '계약을 발급할 권한이 없다. .asc/identities.json 에 `"이름": ["local:계정"]` 형태로 매핑을 추가하라 ' +
|
|
2896
|
+
'(현재 상태는 `asc setup status`).',
|
|
2897
|
+
NO_PAYLOAD: '내보낼 내용이 없다.',
|
|
2898
|
+
GRANT_EXISTS: '같은 id의 계약이 이미 있다.',
|
|
2899
|
+
};
|
|
2900
|
+
// import 만으로 명령이 돌면 안 된다 — bootstrap이 이 모듈을 불러오는 순간 세상이 바뀐다.
|
|
2901
|
+
// bin은 symlink로 놓이므로 realpath로 견준다.
|
|
2902
|
+
const invokedDirectly = (() => {
|
|
2903
|
+
const entry = process.argv[1];
|
|
2904
|
+
if (!entry)
|
|
2905
|
+
return false;
|
|
2906
|
+
try {
|
|
2907
|
+
return realpathSync(entry) === fileURLToPath(import.meta.url);
|
|
2908
|
+
}
|
|
2909
|
+
catch {
|
|
2910
|
+
return false;
|
|
2911
|
+
}
|
|
2912
|
+
})();
|
|
2913
|
+
if (invokedDirectly)
|
|
2914
|
+
process.exitCode = await runAscCommand(process.argv.slice(2));
|