@asc-agent/runtime 0.7.1 → 0.8.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.
@@ -111,6 +111,42 @@ function reconcileHooks(settings, specs) {
111
111
  }
112
112
  return { settings: changed.length > 0 ? { ...settings, hooks } : settings, changed };
113
113
  }
114
+ /**
115
+ * ASC control-plane 을 Host 권한 계층에서 통과시키는 규칙 (E-02, 2층).
116
+ *
117
+ * 0.7.1 실측에서 raw 외부 write 는 ASC Guard 가 막고, 그 자리의 안전한 출구인
118
+ * `asc grant issue` 는 Host 의 권한 판정이 막았다. 막는 길과 나가는 길이 동시에 닫히면
119
+ * 사람이 갇힌다. Host 안에서 우리가 손댈 수 있는 자리는 이 한 줄뿐이다 — 우리 명령을
120
+ * 명시적으로 허용 목록에 올린다. **허용하는 것은 ASC CLI 뿐이고**, 실행 권한이 있는지는
121
+ * 그 다음에 Core 가 판정한다 (Guard allows, then Core decides).
122
+ */
123
+ export const CONTROL_PLANE_ALLOW_RULES = ['Bash(asc:*)'];
124
+ /**
125
+ * settings 의 permissions.allow 에 우리 규칙을 **더하기만** 한다. 남의 항목은 읽지도
126
+ * 고치지도 않는다. 이미 있으면 그대로 둔다 — 중복은 그 자체로 결함이다.
127
+ */
128
+ function reconcileAllow(settings) {
129
+ const permissions = { ...(settings.permissions ?? {}) };
130
+ const allow = [...(permissions.allow ?? [])];
131
+ const added = CONTROL_PLANE_ALLOW_RULES.filter((rule) => !allow.includes(rule));
132
+ if (added.length === 0)
133
+ return { settings, added };
134
+ permissions.allow = [...allow, ...added];
135
+ return { settings: { ...settings, permissions }, added };
136
+ }
137
+ export async function controlPlaneAccess(paths) {
138
+ const settings = (await readJson(locate(paths).settings)) ?? {};
139
+ const permissions = (settings.permissions ?? {});
140
+ const denied = (permissions.deny ?? []).filter((rule) => /^Bash\(\s*asc[\s:)]/.test(rule));
141
+ const allowed = CONTROL_PLANE_ALLOW_RULES.every((rule) => (permissions.allow ?? []).includes(rule));
142
+ if (denied.length > 0)
143
+ return { allowed: false, denied: true, detail: `the host denies ${denied.join(', ')}` };
144
+ return {
145
+ allowed,
146
+ denied: false,
147
+ ...(allowed ? {} : { detail: 'the host has no allow rule for ASC commands — `asc refresh` adds it' }),
148
+ };
149
+ }
114
150
  async function readJson(path) {
115
151
  try {
116
152
  return JSON.parse(await readFile(path, 'utf8'));
@@ -169,6 +205,15 @@ export async function install(paths, now = () => new Date().toISOString(), opts
169
205
  written.push(`${where.settings} (${reconciled.changed.join(', ')} hook)`);
170
206
  }
171
207
  manifest.settingsHook = true;
208
+ // control-plane 허용 규칙 (E-02). hook 등록과 같은 파일이지만 다른 계약이다 —
209
+ // hook 은 우리가 막는 자리이고, 이것은 우리가 **막히지 않는** 자리다.
210
+ const allowed = reconcileAllow((await readJson(where.settings)) ?? {});
211
+ if (allowed.added.length > 0) {
212
+ await mkdir(dirname(where.settings), { recursive: true });
213
+ await writeFile(where.settings, JSON.stringify(allowed.settings, null, 2) + '\n', 'utf8');
214
+ written.push(`${where.settings} (control-plane allow: ${allowed.added.join(', ')})`);
215
+ }
216
+ manifest.permissionAllow = [...new Set([...(manifest.permissionAllow ?? []), ...CONTROL_PLANE_ALLOW_RULES])];
172
217
  await mkdir(dirname(where.manifest), { recursive: true });
173
218
  await writeFile(where.manifest, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
174
219
  return { written, skipped, hookRegistered: true };
@@ -297,6 +342,27 @@ export async function uninstall(paths) {
297
342
  removed.push(`${where.settings} (${dropped.join(', ')} hook entry)`);
298
343
  }
299
344
  }
345
+ // 우리가 넣은 허용 규칙만 걷는다 — manifest 에 적힌 것만 대상이므로, 사람이 직접
346
+ // 넣어 둔 같은 규칙을 지우는 일은 생기지 않는다.
347
+ const mine = manifest.permissionAllow ?? [];
348
+ if (mine.length > 0) {
349
+ const current = (await readJson(where.settings)) ?? {};
350
+ const permissions = (current.permissions ?? {});
351
+ const before = permissions.allow ?? [];
352
+ const allow = before.filter((rule) => !mine.includes(rule));
353
+ if (allow.length !== before.length) {
354
+ if (allow.length > 0)
355
+ permissions.allow = allow;
356
+ else
357
+ delete permissions.allow;
358
+ if (Object.keys(permissions).length === 0)
359
+ delete current.permissions;
360
+ else
361
+ current.permissions = permissions;
362
+ await writeFile(where.settings, JSON.stringify(current, null, 2) + '\n', 'utf8');
363
+ removed.push(`${where.settings} (control-plane allow)`);
364
+ }
365
+ }
300
366
  await rm(where.manifest, { force: true });
301
367
  await rm(dirname(where.manifest), { recursive: true, force: true }).catch(() => { });
302
368
  // 파일만 지우면 빈 skills/<name>/ 이 남는다 (P1 관찰 ⑥). 우리가 만든 디렉터리이므로
@@ -30,8 +30,9 @@ export function skillText() {
30
30
  name: asc
31
31
  description: >-
32
32
  Safely start, resume or continue a Logical Session in a project that has ASC attached.
33
- Triggers — "proceed with ASC", "continue the ASC session", "asc proceed", "start work
34
- with ASC", "ASC로 진행해", "ASC 세션 이어서", "ASC로 작업 시작", or the explicit /asc.
33
+ Triggers — "start this work", "continue the ASC session", "publish it", "wrap it up",
34
+ "proceed with ASC", "ASC로 진행해", "ASC 세션 이어서", "이거 작업해", "게시해",
35
+ "마무리해", or the explicit /asc.
35
36
  Also triggers on work-status questions in an ASC-attached project — "is this
36
37
  implemented?", "what's left to do?", "what should I do now?", "현황 파악",
37
38
  "이거 구현됐나", "남은 작업", "지금 뭐 해야 하나" — because answering those by
@@ -48,7 +49,7 @@ CLI — here you call it, act on the typed outcome, and keep the contract.
48
49
 
49
50
  "Is X implemented?", "what's the status of this work?", "what remains?" — in an
50
51
  ASC-attached project, do NOT settle these by direct host exploration (git log, tracker
51
- reads). Run \`asc proceed --work <KEY>\` first and read its WORK_STATE: it fetches the
52
+ reads). Run \`asc work start <KEY> --json\` first and read its WORK_STATE: it fetches the
52
53
  canonical remote, grades the evidence, and refuses to conclude on a stale or key-only
53
54
  observation — none of which ad-hoc exploration does. Direct exploration may supplement
54
55
  the answer, never replace the judgement. This is mitigation at the model layer; the
@@ -70,20 +71,85 @@ The user does not need to know this sequence. This is the one the skill follows.
70
71
  command and ask them to switch this session's permission mode so they can approve the
71
72
  single run — a standing allow rule for the command was measured and did not work.
72
73
 
73
- 1. Attached? asc setup status
74
- not yet → asc init (it shows profile candidates; a person chooses)
75
- blocked → show the printed reason and remedy to the person, and stop. Do not open it for them
76
- 2. Anything to run? asc proceed --json (act per the table below)
74
+ 1. Where are we? asc status
75
+ not attached → asc setup (it shows profile candidates; a person chooses)
76
+ blocked → show the printed reason and remedy to the person, and stop. Do not open it for them
77
+ 2. Anything to run? asc work start [<WORK-KEY>] --json (act per the table below)
77
78
  3. Check before handing asc preflight (paths and decision rights, both)
78
79
  4. While working asc progress report
79
80
  5. Another part's call asc query open / answer
80
- 6. Wrap up asc session done → tell the person to run asc controller collect
81
+ 6. Send it out asc work publish
82
+ 7. Wrap up asc work finish
81
83
  \`\`\`
82
84
 
83
85
  **Do not open what is blocked.** LOCK_DRIFT, incomplete configuration and an unreadable
84
- canonical source are all a person's call, and \`setup status\` already states the reason
86
+ canonical source are all a person's call, and \`asc status\` already states the reason
85
87
  and the remedy.
86
88
 
89
+ ## Three axes, and they do not stand in for each other
90
+
91
+ \`\`\`text
92
+ Agent Management who owns this work, what its scope is, how far it has come
93
+ Decision Authority whether a person has to decide this
94
+ Execution Mode who runs the approved act — MANUAL (a person) or AUTO (ASC)
95
+ \`\`\`
96
+
97
+ **AUTO is not the opposite of human-in-the-loop.** It does not approve anything on a
98
+ person's behalf, and MANUAL does not mean something was already approved. Changing the
99
+ mode never changes a session's owner, scope, progress or handoff.
100
+
101
+ \`\`\`text
102
+ "수동으로 해" · "let me run it" → asc mode manual
103
+ "ASC가 자동으로 관리해" → asc mode auto
104
+ \`\`\`
105
+
106
+ Stepping down out of AUTO is one command and it is recorded — who said so, and when.
107
+ **Do not use it to get past a block.** If the guard stopped a write, the answer is
108
+ \`asc work publish\`, not a mode change; flipping to MANUAL to push raw is the exact drift
109
+ ASC exists to make visible, and the record makes it visible to the person you work with.
110
+
111
+ **A workspace nobody has chosen a mode for is not in AUTO.** AUTO exists only where a
112
+ person turned it on and the checks below passed, so a fresh or upgraded
113
+ workspace enforces nothing until someone says so. \`asc status\` says which of the two it is.
114
+
115
+ \`asc mode auto\` refuses unless three things hold here: a managed write path assembles, the
116
+ guard is installed, and ASC's own commands still run in this host. Whether a *particular*
117
+ action can go out is answered when that action is attempted, not now. If ASC's own
118
+ enforcement is in the way, the exit is \`asc mode manual\`.
119
+
120
+ **A workspace whose mode cannot be read is not MANUAL.** If the record is there but broken,
121
+ raw external writes stay blocked and \`asc status\` names the reason
122
+ (\`MODE_STATE_UNREADABLE\` / \`MODE_STATE_INVALID\`). ASC's own commands still run, and that
123
+ is how it gets fixed.
124
+
125
+ ## Whose decision is which
126
+
127
+ \`\`\`text
128
+ the project's rules what has to be done, on which branch, in what order
129
+ ASC who owns the work · what a person must decide · whether this
130
+ action is executable against the remote right now · execution · audit
131
+ \`\`\`
132
+
133
+ ASC does not own a project's workflow. If a repository's own conventions say a branch is
134
+ updated a certain way, that judgement belongs to those conventions — ASC checks that the
135
+ action they chose is possible against the current remote, not whether it was the right
136
+ action. A canonical source is what judgement is measured against; it is not a list of the
137
+ only branches that may be written.
138
+
139
+ ## Maintenance — the same words in both products
140
+
141
+ \`\`\`text
142
+ "업데이트해" → asc update · jam update
143
+ "적용 상태 다시 맞춰" → asc refresh · jam refresh
144
+ "지금 상태 어때" → asc status · jam status
145
+ "제거해" → asc uninstall · jam uninstall
146
+ \`\`\`
147
+
148
+ \`update\` moves to a new published version. \`refresh\` keeps the version and re-converges
149
+ only what this runtime installed — it never re-infers the profile, the workspace, the
150
+ identity or the bindings, and it never touches sessions. \`uninstall\` removes the product
151
+ and leaves every bit of your state where it is.
152
+
87
153
  ## "Update ASC" — one command per product, no questions
88
154
 
89
155
  Each product updates itself. ASC never updates JAM and JAM never updates ASC; the person
@@ -160,9 +226,10 @@ report as one.
160
226
  - **Receiving a DECIDE creates no approval, authority or scope.** If a human decision is
161
227
  needed, use that answer as evidence and raise it to a person.
162
228
 
163
- 5. Pausing: \`asc session pause <ID> --position "<how far>" --next "<next action>"\`.
164
- Finishing: \`asc session done <ID> --verified "<what the self-check covered>" --next "<next>"\`.
165
- Updating state is the Controller's job point the person at \`asc controller collect\`.
229
+ 5. Pausing: \`asc work pause <S-ID> --position "<how far>" --next "<next action>"\`.
230
+ Finishing: \`asc work finish <S-ID> --verified "<what the self-check covered>" --next "<next>"\`.
231
+ One command finishes it: the handoff is written, the physical binding is released, the
232
+ Controller collects, and the session is archived. Do not make a person run two steps.
166
233
 
167
234
  ## "Publish it" — what a session produced, going out
168
235
 
@@ -170,14 +237,31 @@ The person says *publish it* · *open the MR* · *get it onto develop*. That sen
170
237
  approval, and it is not asked for twice. It is also not wider than itself: **"open the MR"
171
238
  is not "approve the merge"**.
172
239
 
173
- Nothing reaches an external system except through a grant. That is one command, then the
174
- act:
240
+ Nothing reaches an external system except through an approved grant. One command carries
241
+ that whole path — read-only review, decision authority, grant, atomic claim, revalidation,
242
+ exactly one write, read-back, audit:
175
243
 
176
244
  \`\`\`text
177
- asc grant issue --session <S-ID> --action <key> --target <ref> --body-file <path> --as <actor>
178
- asc grant run <G-ID>
245
+ asc work publish [<S-ID>] --action <key> --target <ref> --body-file <path> --as <actor>
246
+ asc work publish … --review # read the facts and stop. Nothing goes out
179
247
  \`\`\`
180
248
 
249
+ The review is **not a second approval**. The person's instruction already settled who
250
+ decides; the review settles facts — is this the remote this work is bound to, is the commit
251
+ that was approved still the commit that is here, is there already an open change for it. A
252
+ \`NOT_EXECUTABLE\` means the action does not hold as it stands; a \`REVIEW_REQUIRED\` means a
253
+ person has to look at something the facts cannot settle. Neither is a request to re-approve.
254
+
255
+ Afterwards the result is read back. If what comes back differs from what was expected the
256
+ outcome is \`NOT_VERIFIED\` — the write happened, it is not a success, and the grant is
257
+ spent either way. If the outcome could not be determined at all it is \`UNCERTAIN\`: read
258
+ the remote before doing anything else, and never repeat the command.
259
+
260
+ Being bound to one repository is the scope of managed execution, not a preference. If the
261
+ target is a different repository the answer is \`REVIEW_REQUIRED\`, and approving the action
262
+ again does not change it — what has to change is the binding, and that is a separate
263
+ decision.
264
+
181
265
  The action key is the provider's (\`gitlab.mr.create\`, \`gitlab.note.create\`, \`git.push\`,
182
266
  \`coordination.publish\`, \`github.issue_comment.create\`). If nothing bound to this
183
267
  workspace can carry out that action, issuing **fails there** rather than after the person
@@ -190,8 +274,14 @@ Publishing a coordination question is the same shape — \`asc coordination publ
190
274
  <G-ID> --query <X-ID> …\`. Reading (\`coordination status\`, \`coordination observe\`) needs
191
275
  no grant.
192
276
 
193
- **Never** reach for \`git push\`, \`glab\`, or \`gh\` directly. The guard stops those, and
194
- being stopped is not a puzzle to solve — it means the act belongs in a grant.
277
+ **Never** reach for \`git push\`, \`glab\`, or \`gh\` directly while the mode is AUTO. The
278
+ guard stops those, and being stopped is not a puzzle to solve — it means the act belongs in
279
+ \`asc work publish\`. ASC's own commands are never blocked by that guard, so the way out is
280
+ always an asc command, never uninstalling the hook.
281
+
282
+ In MANUAL the guard blocks none of it. It leaves one line saying the write is leaving the
283
+ managed path and pointing at \`asc work publish --review\`; what to do about that is the
284
+ person's call, and the project's rules are what answer it.
195
285
 
196
286
  ## Progress reporting
197
287
 
@@ -230,6 +320,12 @@ A change that gives a person no reason to look again is noise, not a report.
230
320
  portable command is the bootstrap one
231
321
  - Investigate external situations directly — leave reading thread originals to \`asc-inbox\` and take back only what it summarised
232
322
  - Declare that I verified what I built — independent verification is \`asc-review\`
323
+ - Teach a person the internal order (session issue · host bind · grant issue · controller
324
+ collect). On a healthy path they type none of those — \`work start\`, \`work publish\`,
325
+ \`work finish\` cover it
326
+ - Treat AUTO as permission, or MANUAL as approval. Neither mode decides what a person
327
+ must decide
328
+ - Turn enforcement off by removing the product. \`asc mode manual\` is the exit
233
329
  `;
234
330
  }
235
331
  export function inboxSkillText() {
@@ -33,6 +33,7 @@ export declare class GlabApiClient implements GitLabReader, GitLabWriter {
33
33
  constructor(run: ProcessRunner);
34
34
  get<T>(path: string): Promise<GitLabResponse<T>>;
35
35
  post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
36
+ put<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
36
37
  }
37
38
  /**
38
39
  * 읽기 통로. GitLabClient(토큰)와 GlabApiClient(로그인된 도구) 둘 다 이것이다 —
@@ -50,6 +51,14 @@ export declare class GlabApiClient implements GitLabReader, GitLabWriter {
50
51
  */
51
52
  export interface GitLabWriter {
52
53
  post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
54
+ /**
55
+ * GitLab 의 merge 는 `PUT /merge_requests/:iid/merge` 다 (공식 계약).
56
+ *
57
+ * 예전에는 POST 로 보냈다. 그 경로는 승인이 끝난 뒤에 실패하고, 실패한 것이 정말 나가지
58
+ * 않았는지는 그 자리에서 알 수 없다 — 통로가 없는 것과 잘못된 통로로 부르는 것은 다르고,
59
+ * 후자가 더 나쁘다. 통로가 없으면 없다고 말할 수 있게 optional 로 둔다.
60
+ */
61
+ put?<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
53
62
  }
54
63
  export interface GitLabReader {
55
64
  get<T>(path: string): Promise<GitLabResponse<T>>;
@@ -59,6 +68,7 @@ export declare class GitLabClient implements GitLabReader, GitLabWriter {
59
68
  constructor(deps: GitLabClientDeps);
60
69
  get<T>(path: string): Promise<GitLabResponse<T>>;
61
70
  post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
71
+ put<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
62
72
  }
63
73
  /** `group/sub/project!19` 또는 `group/project#7` 을 쪼갠다. */
64
74
  export declare function parseRef(reference: string): {
@@ -51,6 +51,16 @@ export class GlabApiClient {
51
51
  return { ok: false, status: 0, data: null, error: String(error.message ?? error).slice(0, 200) };
52
52
  }
53
53
  }
54
+ async put(path, body) {
55
+ const fields = Object.entries(body).flatMap(([key, value]) => value === undefined ? [] : ['-f', `${key}=${String(value)}`]);
56
+ try {
57
+ const stdout = await this.#run('glab', ['api', '--method', 'PUT', path.replace(/^\//, ''), ...fields]);
58
+ return { ok: true, status: 200, data: JSON.parse(stdout) };
59
+ }
60
+ catch (error) {
61
+ return { ok: false, status: 0, data: null, error: String(error.message ?? error).slice(0, 200) };
62
+ }
63
+ }
54
64
  }
55
65
  export class GitLabClient {
56
66
  #token;
@@ -78,8 +88,14 @@ export class GitLabClient {
78
88
  };
79
89
  }
80
90
  async post(path, body) {
91
+ return this.#write('POST', path, body);
92
+ }
93
+ async put(path, body) {
94
+ return this.#write('PUT', path, body);
95
+ }
96
+ async #write(method, path, body) {
81
97
  const response = await this.#fetch(`${this.#baseUrl}${path}`, {
82
- method: 'POST',
98
+ method,
83
99
  headers: {
84
100
  accept: 'application/json',
85
101
  'content-type': 'application/json',
@@ -1,8 +1,16 @@
1
1
  import type { CanonicalSnapshot } from '../../core/model/entities.ts';
2
+ import type { RemoteFacts } from '../../core/execution/remote-review.ts';
2
3
  import type { BaselineQuery, ExternalAction, ExternalActionResult, ScmPort, ThreadSnapshot } from '../../ports/scm.ts';
3
4
  import { type GitLabReader, type GitLabWriter } from './client.ts';
4
5
  /** 이 adapter 가 수행할 수 있는 행위. 목록에 없는 것은 하지 않는다. */
5
6
  export declare const GITLAB_ACTIONS: readonly ["coordination.publish", "gitlab.note.create", "gitlab.mr.create", "gitlab.mr.merge", "gitlab.issue.update", "git.push"];
7
+ /**
8
+ * 되돌려 읽을 수 있는 행위 (0.8.0 보정 P1-2).
9
+ *
10
+ * `coordination.publish` 가 여기 없는 이유는 그 실행이 이 통로가 아니라 조율 표면에서
11
+ * 나가기 때문이다 — 자기가 하지 않은 일을 확인했다고 말하지 않는다.
12
+ */
13
+ export declare const VERIFIABLE_ACTIONS: readonly ["git.push", "gitlab.mr.create", "gitlab.mr.merge", "gitlab.note.create", "gitlab.issue.update"];
6
14
  export type GitLabScmDeps = {
7
15
  reader: GitLabReader;
8
16
  writer: GitLabWriter;
@@ -14,6 +22,8 @@ export type GitLabScmDeps = {
14
22
  }>>;
15
23
  /** `git.push` 를 수행할 자리. 없으면 push 는 할 수 없다고 답한다. */
16
24
  repoRoot?: string;
25
+ /** 이 결합이 가리키는 원격 이름. 검수가 URL·신원을 읽을 자리다. */
26
+ remoteName?: string;
17
27
  /** 테스트가 실제 git 을 부르지 않게 하는 통로. */
18
28
  git?: (args: readonly string[], cwd: string) => Promise<{
19
29
  ok: boolean;
@@ -39,5 +49,26 @@ export declare class GitLabScm implements ScmPort {
39
49
  */
40
50
  /** 이 통로가 아는 행위. execute 의 분기와 같은 목록이어야 한다. */
41
51
  supports(action: string): boolean;
52
+ /**
53
+ * 나가기 **전에** 읽히는 사실 (0.8.0 §D·§L·§M·§N). 판정은 하지 않는다 — Core 의
54
+ * Remote Review 가 이 사실로 판정한다. 여기서 쓰는 것은 하나도 없다.
55
+ */
56
+ review(action: ExternalAction): Promise<RemoteFacts>;
57
+ /** 되돌려 읽을 수 있는 행위. `execute` 의 분기와 이 목록이 갈리면 그것이 결함이다. */
58
+ verifies(action: string): boolean;
59
+ /**
60
+ * 나간 **뒤에** 밖에서 읽히는 사실. 명령이 0 으로 끝났다는 것은 성공이 아니다.
61
+ */
62
+ verify(action: ExternalAction, result: {
63
+ resultRef: string;
64
+ }): Promise<{
65
+ observed: Record<string, string | undefined>;
66
+ unsupported?: boolean;
67
+ }>;
42
68
  execute(action: ExternalAction): Promise<ExternalActionResult>;
43
69
  }
70
+ /**
71
+ * 원격 URL 에서 프로젝트 신원만 꺼낸다 — `git@host:group/p.git` 도 `https://host/group/p` 도
72
+ * 같은 `group/p` 다. 결합과 견주는 값이므로 형태가 아니라 신원이어야 한다.
73
+ */
74
+ export declare function identityOf(url: string): string;